Hub

TypeScript

Bài này nói về TypeScript mức senior cho phỏng vấn Vue: làm type không cho phép state sai. UserId không truyền vào chỗ cần OrderId được, UI không thể vừa loading vừa error, DTO PATCH không gửi được passwordHash. Dành cho engineer đã compile được, giờ cần một quy tắc team có lý do.

Đọc 2.1 trước (interface vs type, generic, narrowing). Người phỏng vấn hỏi discriminated union và một useFetch không nói dối, nhiều hơn Partial vs Pick. Rồi đọc 2.2 về utility, guard, mapped và conditional type. Ưu tiên Vue 3 script setup; React là bản đối chiếu. Với mỗi topic, nêu rõ type ngăn gì, runtime vẫn cho phép gì, và bạn bắt phần còn lại bằng parser hoặc test thế nào.


Tóm tắt: interface và type đều đặt tên shape object. Dùng type cho union, alias mapped và conditional, và brand. Dùng interface khi cần declaration merging.

Vì sao quan trọng: Một người trong team interface một union các state API “cho consistency”. Người khác từ chối interface vì “type hiện đại hơn”. Người phỏng vấn muốn quy tắc team có lý do, không phải sở thích Twitter. Hai file interface User merge mà không cảnh báo. Đó là cách bạn mất một field đang nhìn trước mặt.

  1. Cách hoạt động: Cả hai mô tả shape object. type còn alias được union, intersection, mapped type, hoặc primitive. interface không đặt tên được union.
  2. interface declaration merging: hai interface User cùng scope thành một. Đó là mục đích của Window, module augmentation Vue, và Express.
  3. extends trên interface thì fail rõ khi xung đột. Intersection A & B có thể kéo field về never thầm hơn.
  4. Shape object dùng được cả hai. Theo repo. Style lẫn trong một PR mới là chi phí thật, không phải chuyện chọn keyword “hiện đại”.
type ApiState<T> =
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error }

// interface: merge là mục đích, một env.d.ts
declare global {
  interface Window {
    analytics?: { track(event: string): void }
  }
}

interface User { id: string }
interface User { name: string } // merge, có thể giấu field thiếu

Điều gì xảy ra: ApiState là union, nên phải là type. Window.analytics là declaration merging, nên phải là interface. Hai khối interface User thành một type; file thứ ba có thể thêm field bạn không thấy trong PR.

CầnChọnVì sao
Union / brand / mapped aliastypeinterface không đặt tên được
Augment Window / VueinterfaceDeclaration merging là tính năng
DTO object trong code appCả hai; theo repoStyle lẫn tốn hơn keyword
Field xung độtinterface extendsFail rõ vs & → never

Góc nhìn senior

  • Mặc định team: type cho union và alias, interface cho merge. Shape object theo file bạn đang ở.
  • Hai file interface User merge không cảnh báo. Đó là tính năng cho Window và bẫy cho DTO.
  • Mình thà extends fail rõ còn hơn intersection sập thành never.
  • Module augmentation Vue (ComponentCustomProperties) là interface có chủ đích. Đừng “hiện đại hóa” nó thành type.
  • Brand (type UserId = string & { readonly brand: unique symbol }) phải là type. Ở sản phẩm admin, state API là type union; Window.analytics giữ interface trong env.d.ts để merge được.

Lỗi hay gặp

  • Triệu chứng: union các state API là interface. Nguyên nhân: “luôn dùng interface”. Cách xử lý: type cho union.
  • Triệu chứng: một field biến mất lúc review. Nguyên nhân: interface User khác đã merge. Cách xử lý: alias type cho DTO, hoặc một interface mỗi tên.
  • Triệu chứng: A & B làm field thành never. Nguyên nhân: intersection không tương thích. Cách xử lý: extends hoặc union.
  • Triệu chứng: Vue declare module viết lại thành type. Nguyên nhân: “type thì hiện đại”. Cách xử lý: giữ interface để nó merge.

Nhà tuyển dụng hay hỏi gì

Một người trong team mở PR interface một union các state API “cho consistency”. Người khác từ chối interface vì “type hiện đại hơn”. Họ muốn quy tắc team có lý do, không phải sở thích Twitter.

Trả lời khi phỏng vấn

Mình dùng type cho union, brand, và alias mapped, còn interface khi muốn declaration merging: augment Vue, Express, hoặc Window. Shape object dùng được cả hai; mình theo repo vì style lẫn mới là chi phí thật. Hai file interface User merge không cảnh báo, đó là cách mất field đang nhìn trước mặt. Mình thà extends fail rõ còn hơn intersection sập thành never. Mình không hiện đại hóa module augmentation Vue thành type, và brand phải là type vì chúng là intersection với unique symbol. Ở sản phẩm admin, state machine API là type union, còn Window.analytics là interface trong env.d.ts.

Nhớ nhanh

  • type cho union, brand, alias mapped. interface cho declaration merging.
  • Hai file interface User merge thầm. Đó là bẫy DTO.
  • Theo repo cho shape object. Style lẫn tốn hơn keyword.
  • Augmentation Vue / Window giữ interface để merge được.

Câu hỏi nối tiếp thường gặp

  • interface đặt tên union được không? Không. Đó là type.
  • Khi nào merge là tính năng? Window, Vue ComponentCustomProperties, Express Request.
  • Vì sao A & B thành never? Field không tương thích. extends fail sớm hơn.
  • Mặc định team trên repo Vue greenfield? type cho union và DTO; interface chỉ khi cần merge.

Tóm tắt: Generic là type parameter: đối số của hàm, nhưng cho type. Dùng khi caller biết type mà bạn không biết. Ràng buộc những gì implementation cần. Đừng generic một type giả ở ranh giới network.

Vì sao quan trọng: Bạn bọc fetch hoặc viết composable kiểu useAsyncData. Candidate mức mid viết function wrap<T>(value: T) rồi dừng. Người phỏng vấn muốn ràng buộc, default, và khi nào không generic. useFetch<T>() không có parser là type bạn truyền vào, không phải type bạn kiếm được.

  1. Cách hoạt động: <T> khai báo lúc gọi (hoặc trên type alias). TypeScript suy ra T từ đối số khi được. Nếu mọi caller viết <Foo>, inference đã thất bại.
  2. T extends { id: string } ràng buộc những gì thân hàm dùng được. Đừng generic “phòng khi”. Nếu T chỉ xuất hiện một lần như annotation trả về, bạn muốn một type có tên.
  3. Mặc định unknown ở ranh giới network để inference không thành {}. Parse (zod) hoặc truyền parse: (data: unknown) => T để T là type kiếm được.
  4. Composable Vue: ref<T | null>(null) kèm parser. React useQuery<T> cùng một kiểu nói dối nếu fetcher trả any.
type JsonParser<T> = (data: unknown) => T

export function useApi<T>(url: string, parse: JsonParser<T>) {
  const data = ref<T | null>(null)
  const error = ref<Error | null>(null)
  async function reload(signal?: AbortSignal) {
    const raw: unknown = await api.get(url, { signal })
    data.value = parse(raw) // T kiếm được, không phải assert
  }
  return { data, error, reload }
}

function upsertById<T extends { id: string }>(rows: T[], row: T): T[] {
  return [...rows.filter((r) => r.id !== row.id), row]
}

Điều gì xảy ra: useApi không tin URL. Caller đưa parse, nên T là thứ hàm đó trả về. upsertById ràng buộc id vì thân hàm cần nó. useFetch<User>() với as User bên trong vẫn compile và sai trên trang lỗi HTML 200.

PatternKhi nàoSmell
parse: (unknown) => TRanh giới network / storageuseFetch<T>() không parse
T extends { id: string }Thân hàm cần một fieldT không ràng buộc, không đọc
Không genericMột domain type có tênT dùng một lần làm return type

Góc nhìn senior

  • Generic khi caller biết type bạn không biết. Type có tên khi domain đã đóng.
  • Default type parameter là unknown, không phải {} hay any.
  • API over-generic (<T, U, V, K>) khó đọc. Hai hàm hơn một chữ ký quá khéo.
  • Nếu mọi caller viết <Foo>, hãy sửa inference (thêm đối số nhắc T) chứ đừng ghi “xin hãy truyền type param”.
  • Zod / Valibot z.infer<typeof schema> là cách T được kiếm ở ranh giới năm 2026. Ở sản phẩm admin, composable list nhận hàm parse để T kiếm được, không assert từ URL.

Lỗi hay gặp

  • Triệu chứng: useFetch<User>() trả HTML như User. Nguyên nhân: assertion, không parser. Cách xử lý: unknown + parse.
  • Triệu chứng: T extends object vẫn không đọc id. Nguyên nhân: ràng buộc yếu. Cách xử lý: T extends { id: string }.
  • Triệu chứng: caller luôn viết <Foo>. Nguyên nhân: không đối số nào nhắc T. Cách xử lý: một parse hoặc giá trị mẫu.
  • Triệu chứng: function wrap<T>(x: T): T chỉ trả x. Nguyên nhân: generic để khoe. Cách xử lý: xóa <T>.

Nhà tuyển dụng hay hỏi gì

Bạn đang bọc fetch hoặc viết composable kiểu useAsyncData. Candidate mid viết function wrap<T>(value: T) rồi coi như xong. Họ muốn ràng buộc, default, và khi nào thì không generic.

Trả lời khi phỏng vấn

Generic dành cho caller biết type mà mình không biết. Mình ràng buộc khi implementation cần id, default unknown để inference không thành object rỗng, và từ chối generic mà T chỉ xuất hiện một lần như annotation return. useFetch T không parser là type giả bạn truyền vào. Generic chỉ ghi intent; Zod hoặc Valibot mới enforce. Nếu mọi caller viết Foo trong ngoặc nhọn, inference đã thất bại. Wrapper rỗng chỉ return lại argument thì không nên generic. Chữ ký over-generic với bốn type parameter khó đọc; hai hàm hơn một chữ ký quá khéo. Ở sản phẩm admin, composable nhận hàm parse để T được kiếm ở ranh giới.

Nhớ nhanh

  • Generic khi caller biết T. Ràng buộc đúng thứ thân hàm cần.
  • useFetch<T>() không parser là type bạn assert, không phải type bạn kiếm được.
  • Nếu mọi caller viết <Foo>, inference đã thất bại. Sửa lại đối số.
  • Đừng generic hàm mà T chỉ xuất hiện như annotation return.

Câu hỏi nối tiếp thường gặp

  • Khi nào generic là thừa? Một domain type đã có tên, hoặc T chỉ dùng một lần làm return type.
  • Giữ inference mà không cần type argument thế nào? Đặt T vào một argument (parse, mẫu, schema).
  • Vì sao default unknown chứ không phải any? any bị leak. unknown buộc phải narrow.
  • Vue vs React? Cùng quy tắc: composable hoặc hook trả ref<T> vẫn cần parser ở ranh giới.

Tóm tắt: Narrowing là cách TypeScript thu hẹp union sau một check: typeof, in, instanceof, equality, và discriminated union. Model UI async bằng union có tag, để loading và error không thể cùng true.

Vì sao quan trọng: Spinner, form, và banner lỗi có thể hiện cùng lúc vì state là { loading: boolean; data?: T; error?: Error }. Người phỏng vấn muốn discriminated union như một state machine UI, không phải typeof trên string.

  1. Cách hoạt động: Sau if (state.status === 'success'), TypeScript chỉ còn giữ variant đó. Tag literal (status) là discriminant.
  2. typeof / in / instanceof / equality cũng narrow. instanceof fail khi qua iframe: hãy dùng một kind do bạn định nghĩa cho giá trị đi xuyên realm.
  3. Cờ boolean ổn cho nút một dòng, nhưng sai khi có retry hoặc stale-while-revalidate. data stale là variant khác (error + data tùy chọn), không phải data? trên mọi state.
  4. Narrowing là lúc compile. JSON vẫn unknown đến khi có parser. Template Vue thường mất narrowing. Hãy derive variant đã gán nhãn trong script.
type AsyncState<T, E = Error> =
  | { status: 'idle' }
  | { status: 'loading'; abort: AbortController }
  | { status: 'success'; data: T }
  | { status: 'error'; error: E; data?: T } // có data = stale

function label(state: AsyncState<User>) {
  if (state.status === 'success') return state.data.name
  if (state.status === 'error') return state.error.message
  return '…'
}

function assertNever(x: never): never {
  throw new Error(String(x))
}

Điều gì xảy ra: Sau check success, data là bắt buộc. loading && error không compile vì hai tag là variant khác nhau. assertNever là switch đủ nhánh cho status: 'refreshing' sau này bạn chưa xử lý. data tùy chọn trên error là stale-while-revalidate, không phải boolean thứ ba.

CheckNarrow thànhLưu ý
status === 'success'Chỉ variant đóTag phải là literal, không phải string
typeof x === 'string'Primitivetypeof null vẫn 'object'
in / instanceofObject / classinstanceof fail khi khác realm
assertNeverUnion đủ nhánhTemplate Vue có thể không thấy

Góc nhìn senior

  • Discriminated union chính là state machine của UI. Túi boolean là lý do spinner + error + form hiện cùng lúc.
  • Đủ nhánh: assertNever trong switch. Thêm variant phải làm vỡ build.
  • Template Vue làm narrowing không còn sound. Derive nhãn bằng computed trong script.
  • Narrowing không validate JSON. Parse trước (unknown → User), rồi mới switch status.
  • Stale-while-revalidate là error kèm data tùy chọn, hoặc tag success + refreshing, không phải ba boolean. Ở sản phẩm admin, trang list là union đó, nên retry không thể hiện spinner và banner lỗi cùng lúc.

Lỗi hay gặp

  • Triệu chứng: spinner và error cùng lúc. Nguyên nhân: { loading, error, data? }. Cách xử lý: union status.
  • Triệu chứng: state.data.name trong template lúc loading. Nguyên nhân: template mất narrowing. Cách xử lý: computed trong script.
  • Triệu chứng: instanceof Array false từ iframe. Nguyên nhân: realm. Cách xử lý: Array.isArray / một kind.
  • Triệu chứng: status mới vẫn compile mọi chỗ. Nguyên nhân: không check never. Cách xử lý: assertNever trong switch.

Nhà tuyển dụng hay hỏi gì

Spinner, form, và banner lỗi có thể hiện cùng lúc vì state là { loading: boolean; data?: T; error?: Error }. Họ muốn discriminated union như một state machine UI, không phải typeof trên string.

Trả lời khi phỏng vấn

Mình model UI async thành idle, loading, success, hoặc error với tag status literal. Sau check success thì data là bắt buộc, loading và error không compile được cùng lúc. Cờ boolean ổn cho nút một dòng, sai khi có retry hoặc stale-while-revalidate. Data stale là variant khác, không phải data tùy chọn trên mọi state. Narrowing là lúc compile; JSON vẫn unknown đến khi có parser. Template Vue thường mất narrowing, nên mình tính nhãn trong script và đặt assertNever trong switch để status mới làm vỡ build. Ở sản phẩm admin, trang list là union đó, không phải ba boolean.

Nhớ nhanh

  • Discriminated union khiến state UI bất hợp pháp không biểu diễn được.
  • Narrowing là lúc compile. Parse JSON unknown trước khi switch.
  • Template Vue mất narrowing. Gán nhãn variant trong script.
  • assertNever biến status mới thành lỗi compile.

Câu hỏi nối tiếp thường gặp

  • Vì sao không { loading: boolean; data?: T }? loading && data và error && loading đều compile. Một tag thì cấm cả hai.
  • Giữ bảng stale hiện khi error thế nào? data tùy chọn trên variant error, hoặc tag refreshing.
  • typeof có narrow null không? typeof null === 'object'. Check == null trước.
  • Đủ nhánh trong template Vue SFC? Làm trong script. Template không phải switch.

Tóm tắt: Utility có sẵn viết lại một type: Partial, Pick, Omit, Record, ReturnType, Awaited, NonNullable. Chúng là structural và nông. Hãy compose một DTO tường minh; đừng ship Partial<User> làm body PATCH.

Vì sao quan trọng: Bạn cần body PATCH cho User. Ai đó viết Partial<User> rồi ship id và passwordHash như field ghi tùy chọn. Người khác dùng Omit<User, 'password'> và nghĩ credentials.password lồng bên trong đã biến mất. Hàm HTTP vẫn nhận nguyên User.

  1. Cách hoạt động: Partial<T> biến mọi key thành tùy chọn. Pick<T, K> giữ key. Omit<T, K> là { [P in Exclude<keyof T, K>]: T[P] } và nông: object lồng được copy nguyên.
  2. Record<K, V> là index của các key K. Record<string, V> giấu key thiếu. Record<Role, V> với union role mới hữu ích.
  3. ReturnType<typeof fn> và Awaited<T> nối các wrapper promise. Type client một lần; đừng tự bóc ba lớp Axios.
  4. Readonly<T> là lúc compile. Object.freeze freeze nông lúc runtime. Vue readonly() là proxy. Chúng không thay thế lẫn nhau. NonNullable<T> chỉ bỏ null | undefined ở tầng trên.
type User = {
  id: string
  name: string
  email: string
  role: 'admin' | 'user'
  passwordHash: string
  profile: { bio: string; passwordHint: string }
}

type UserPatch = Partial<Pick<User, 'name' | 'email' | 'role'>>
type UserPublic = Omit<User, 'passwordHash'> // passwordHint lồng vẫn còn

function patchUser(id: string, body: UserPatch) {
  return api.patch('/users/' + id, body)
}

const ok: UserPatch = { name: 'Ada' }
// patchUser(id, fullUser) không compile: DTO đã làm đúng việc

Điều gì xảy ra: UserPatch gửi được name, email, hoặc role, không gì khác. User đầy đủ không assign được, nên passwordHash không lọt ra network. UserPublic vẫn còn profile.passwordHint vì Omit nông: bạn phải tự gỡ object lồng.

UtilityLàm gìBẫy
Partial<T>Mọi key tùy chọnPATCH dở: id? và secret thành field ghi
Pick / OmitGiữ / bỏ key nôngSecret lồng sống sót sau Omit
Record<K, V>Object các key KRecord<string, V> giấu key thiếu
Readonly / AwaitedCờ compile / bóc PromiseKhông phải Object.freeze; đừng bóc ba client

Góc nhìn senior

  • DTO là type mà hàm HTTP nhận vào. Truyền được cả User thì DTO đã fail.
  • Partial<User> biến id? trong khi API vẫn bắt buộc, và mở passwordHash như field ghi được.
  • Omit chạy trên từng nhánh union và có thể làm mất union. Dùng Exclude khi ý bạn là các member, không phải các key.
  • Đừng giữ ba bản User viết tay nếu một union Pick là đủ. Chỉ viết tay khi format trên network khác (snake_case, chuỗi ISO).
  • Required<T> trên object lồng optional không deep-require: cùng bẫy nông như Partial. Kiểm bằng type test rằng UserPatch từ chối passwordHash.

Lỗi hay gặp

  • Triệu chứng: PATCH làm trống id hoặc gửi passwordHash. Nguyên nhân: Partial<User>. Cách xử lý: Partial<Pick<User, writable keys>>.
  • Triệu chứng: passwordHint lồng vẫn còn trên type public. Nguyên nhân: Omit nông. Cách xử lý: gỡ object lồng.
  • Triệu chứng: Record<string, Role> nhận mọi key. Nguyên nhân: index signature. Cách xử lý: Record<Role, V> với union.
  • Triệu chứng: spread partial vào User vẫn compile. Nguyên nhân: as User. Cách xử lý: DTO là type đối số, không assertion.

Nhà tuyển dụng hay hỏi gì

Bạn cần body PATCH cho User. Có người viết Partial<User> rồi ship id và passwordHash như field ghi optional. Người khác dùng Omit<User, 'password'> và tưởng credentials.password lồng đã biến mất.

Trả lời khi phỏng vấn

Mình không ship Partial của User làm type PATCH, vì nó biến id và passwordHash thành field ghi optional. Mình compose Partial của Pick các key ghi được để hàm HTTP không nhận cả User. Omit nông: passwordHint lồng vẫn còn đến khi mình gỡ object lồng. Readonly của T là cờ lúc compile, Object.freeze là freeze nông lúc runtime, Vue readonly là proxy: ba thứ không thay thế lẫn nhau. Record của string giấu key thiếu; Record của union role mới hữu ích. Awaited của ReturnType là glue tiện, nhưng mình type client một lần thay vì unwrap ba wrapper Axios. Format trên network dùng snake_case thì mình viết tay DTO; không thì một Pick hơn ba type song sinh. Ở admin nội bộ, helper PATCH chỉ nhận UserPatch, và type test từ chối passwordHash.

Nhớ nhanh

  • Partial<User> là type PATCH tệ. Hãy Pick các key ghi được.
  • Omit / Partial / Required đều nông. Secret lồng sống sót.
  • Readonly<T> không phải Object.freeze và không phải Vue readonly().
  • DTO là type đối số HTTP. Nếu cả User lọt, DTO đã fail.

Câu hỏi nối tiếp thường gặp

  • Vì sao Partial<User> là type PATCH tệ? Nó biến id? và mở secret như field ghi. Compose Partial<Pick<…>>.
  • Cho một secret lồng mà Omit không gỡ. profile.passwordHint vẫn còn đến khi bạn gỡ object lồng.
  • Readonly<T> vs Object.freeze vs Vue readonly()? Cờ compile, freeze nông, và proxy. Chỉ freeze và proxy mới throw hoặc no-op lúc runtime.
  • Omit làm gì với discriminated union? Nó chạy trên từng nhánh và có thể làm mất union. Dùng Exclude trên member khi đó mới là ý bạn.

Tóm tắt: Predicate (x is T) hoặc assertion (asserts x is T) là contract bạn ký với compiler. Kiểm đủ field hoặc parse. Đừng bao giờ return true as x is T. Ở ranh giới network, type là unknown.

Vì sao quan trọng: Một isUser(x: unknown): x is User trả true nếu 'id' in x thì vẫn compile, và sai. Người phỏng vấn muốn predicate bạn dám ký, và kỷ luật không as User ở ranh giới tin cậy.

  1. Cách hoạt động: Sau if (isAbortError(e)), TypeScript giữ DOMException. Thân hàm không bị kiểm theo predicate: bạn có thể nói dối.
  2. asserts x is T phải throw khi fail. Dùng lúc startup (assert(env.API_URL)), không dùng trên đường render. Thân hàm chỉ return thì crash chỉ thấy ở production.
  3. Ở ranh giới network hoặc storage, parse (zod / Valibot) rồi infer T từ schema. Guard viết tay dành cho union của bạn (isAbortError, isSuccess).
  4. instanceof fail khi qua iframe và package bị nhân đôi. Ưu tiên discriminant kind do bạn đặt. Narrow lại trong Vue watch và interceptor axios: chúng mất phần đã narrow.
function isAbortError(e: unknown): e is DOMException {
  return e instanceof DOMException && e.name === 'AbortError'
}

function assertUnreachable(x: never): never {
  throw new Error('unreachable: ' + String(x))
}

function parseUser(data: unknown): User {
  return UserSchema.parse(data) // throw → error state của caller
}

// ❌ nói dối: object chỉ có id thì không phải User
function isUser(x: unknown): x is User {
  return typeof x === 'object' && x !== null && 'id' in x
}

try {
  await reload()
} catch (e: unknown) {
  if (isAbortError(e)) return
  throw e
}

Điều gì xảy ra: isAbortError kiểm name, không chỉ instanceof, nên một DOMException ngẫu nhiên không bị coi là cancel. parseUser throw khi payload sai, nên error state của caller là thật. isUser vẫn compile và để { id: 1 } lọt như User. assertUnreachable là helper never phủ hết nhánh.

Công cụKhi nàoDối cần để ý
Predicate x is TUnion của bạn'id' in x như x is User
asserts x is TStartup / bất biếnThân hàm không throw
Parse schemaNetwork / storageas User trên JSON
Array.isArrayMảngKhông chứng minh type phần tử

Góc nhìn senior

  • Predicate là contract bạn ký: kiểm đủ field hoặc parse. Đừng bao giờ return true as x is T.
  • Schema lúc runtime hay hơn guard viết tay cho mọi thứ đã qua network. Guard dành cho isAbortError và isSuccess.
  • Hàm assertion phải throw, nên để lúc startup, không để trên đường render. asserts sai là crash chỉ thấy ở production.
  • instanceof fail khi qua realm và khi bundler nhân đôi package. Ưu tiên kind do bạn đặt.
  • Guard sai nhiễm sang phần còn lại của file, kể cả noUncheckedIndexedAccess. Guard lại trong callback watch. Ở admin nội bộ, catch dùng unknown và isAbortError, còn User đến từ UserSchema.parse.

Lỗi hay gặp

  • Triệu chứng: { id } lọt như User. Nguyên nhân: 'id' in x. Cách xử lý: đủ field, hoặc Zod.
  • Triệu chứng: x.name.toUpperCase() crash sau guard. Nguyên nhân: chỉ kiểm typeof === 'object'. Cách xử lý: parse hoặc kiểm name.
  • Triệu chứng: x is User[] sau Array.isArray. Nguyên nhân: phần tử chưa type. Cách xử lý: kiểm phần tử hoặc parse.
  • Triệu chứng: assert(x) không throw rồi crash sau. Nguyên nhân: thân rỗng. Cách xử lý: throw khi fail.

Nhà tuyển dụng hay hỏi gì

Một isUser(x: unknown): x is User trả true nếu 'id' in x. Compile được. Là sai. Họ muốn predicate và hàm assertion bạn dám ký, và kỷ luật không as User ở ranh giới tin cậy.

Trả lời khi phỏng vấn

Predicate là contract mình ký. Có id trong x không có nghĩa là x is User. Mình kiểm đủ field, hoặc để Zod infer T từ schema. Ở ranh giới network type là unknown; sau parse là User. Mình không bao giờ return true as x is T. Assert phải throw, nên để lúc startup, không để trên đường render. instanceof fail khi qua realm, nên mình ưu tiên kind do mình đặt, và mình narrow lại trong callback Vue watch vì chúng mất phần đã narrow. Array.isArray không chứng minh type phần tử. Guard sai nhiễm sang phần còn lại của file, kể cả noUncheckedIndexedAccess. Ở admin nội bộ, catch dùng unknown và isAbortError, còn User đến từ UserSchema.parse.

Nhớ nhanh

  • Predicate là một contract. 'id' in x không phải x is User.
  • Schema ở ranh giới network. Guard viết tay cho union của bạn.
  • asserts không throw là crash chỉ thấy ở production.
  • Narrow lại trong watch và interceptor. Chúng mất phần đã narrow.

Câu hỏi nối tiếp thường gặp

  • Vì sao 'id' in x chưa đủ cho x is User? Field thừa và thiếu vẫn lọt. Kiểm đủ key hoặc parse.
  • Predicate khác hàm assertion thế nào? Predicate trả boolean và narrow một if. Assert throw và để lúc startup.
  • Type catch (e) khi useUnknownInCatchVariables thế nào? e là unknown. Narrow bằng isAbortError hoặc parse.
  • Vì sao instanceof ApiError fail giữa app và package dùng chung? Constructor bị nhân đôi. Ưu tiên discriminant kind.

Tóm tắt: Mapped type là “với mỗi key của T, tạo một property”. Map trên keyof T để form và error map không lệch. Ưu tiên Partial<Record<keyof T, string>> hơn việc viết lại Readonly.

Vì sao quan trọng: Người phỏng vấn không muốn bạn viết lại Readonly. Họ muốn một mapping production: error form gắn theo form, hoặc object “mọi flag đều boolean”, không list key song song có thể lệch.

  1. Cách hoạt động: { [K in keyof T]: … } đi qua các key. Mapped type homomorphic giữ readonly và optional đến khi bạn viết lại key theo cách làm chúng bị reset.
  2. Modifier: +? / -? và readonly / -readonly. -? bắt buộc mọi key: đó là map “touched” Record<keyof T, boolean>.
  3. Key remapping (as) dành cho emit update:${K} và để bỏ key bằng never. Nó thừa nếu chỉ đổi id thành userId một lần.
  4. keyof (A | B) là giao của các key, không phải union hai form. Map từng member nếu ý bạn là hai form. Mapped type nông; deep-partial rất dễ sai.
type FormErrors<T> = Partial<Record<keyof T, string>>
type Touched<T> = { [K in keyof T]-?: boolean }

type Profile = { name: string; email: string; age: number }
type ProfileErrors = FormErrors<Profile>
type ProfileTouched = Touched<Profile>

function showErrors(errors: ProfileErrors) {
  if (errors.email) banner(errors.email)
  // thêm phone vào Profile thì chỗ này phải quyết
}

type UpdateEmit<T> = {
  [K in keyof T as `update:${Extract<K, string>}`]: [T[K]]
}

const errors: ProfileErrors = { email: 'required' }
const touched: ProfileTouched = { name: true, email: false, age: false }

Điều gì xảy ra: ProfileErrors có thể có name, email, hoặc age, không key nào khác. Thêm phone vào Profile thì ProfileErrors và Touched buộc UI phải quyết. Touched dùng -? nên mọi key là bắt buộc. UpdateEmit remap mỗi key thành update:${K} cho emit có type.

Nhu cầuViếtTránh
Map error formPartial<Record<keyof T, string>>Interface song song viết tay
Mọi key bắt buộc{ [K in keyof T]-?: boolean }Cờ tùy chọn quên một field
Bỏ / đổi tên keyas never / remapping asĐổi tên một lần mà viết mapped type

Góc nhìn senior

  • Thêm field vào T phải làm form lỗi đến khi UI xử lý. Đó là lý do map trên keyof T.
  • Nếu Partial<Record<keyof T, string>> đủ rồi thì dùng. Đừng viết mapped type riêng để trông senior.
  • keyof (A | B) là giao các key, nên union hai form mới làm team bất ngờ. Map từng phần tử.
  • Key remapping dành cho emit update:K hoặc bỏ key bằng never, không phải để đổi tên một lần.
  • Mapped type là nông. Cần deep-partial thì lấy helper quen thuộc. Vue v-for trên Object.keys type key thành string: kèm helper có type.

Lỗi hay gặp

  • Triệu chứng: field phone mới không có chỗ error. Nguyên nhân: interface error viết tay. Cách xử lý: map keyof T.
  • Triệu chứng: union hai form mất key. Nguyên nhân: keyof (A | B) là giao. Cách xử lý: map từng phần tử.
  • Triệu chứng: cờ optional biến mất. Nguyên nhân: viết lại không homomorphic. Cách xử lý: { [K in keyof T]: … } hoặc giữ Partial.
  • Triệu chứng: Object.keys(errors) là string[]. Nguyên nhân: TS widening. Cách xử lý: helper có type, không as.

Nhà tuyển dụng hay hỏi gì

Họ không muốn bạn viết lại Readonly. Họ muốn một mapping production: error form gắn theo key của form, hoặc object “mọi cờ đều boolean”, không list key song song rồi lệch.

Trả lời khi phỏng vấn

Mapped type là cách mình giữ form và error map khỏi lệch. Partial của Record của keyof T của string là đủ, mình không viết lại Readonly để trông senior. Thêm phone vào Profile phải làm UI lỗi đến khi team mình xử lý. keyof của union là giao các key, nên union hai form mới làm bọn mình bất ngờ; mình map từng phần tử nếu ý mình là hai form. Minus-optional biến map touched thành mọi key bắt buộc. Key remapping dành cho emit kiểu update-colon hoặc bỏ key bằng never, không phải đổi tên một lần. Mapped type nông nên mình lấy helper quen thuộc nếu cần deep-partial. Ở sản phẩm admin, form profile dùng FormErrors của Profile, thêm field là lỗi compile đến khi banner xử lý.

Nhớ nhanh

  • Thêm field vào T phải làm form lỗi đến khi UI xử lý.
  • Ưu tiên Partial<Record<keyof T, string>> hơn mapped type tự viết.
  • keyof (A | B) là giao các key. Map từng phần tử.
  • -? tạo map “touched” bắt buộc. Chỉ remap bằng as khi cần.

Câu hỏi nối tiếp thường gặp

  • Vì sao không giữ interface ProfileErrors song song viết tay? Field mới sẽ không hiện. Map keyof T.
  • Bỏ key trong mapped type thế nào? Remap as never cho DTO public, hoặc Omit nếu rõ hơn.
  • keyof (A | B) trả về gì? Giao các key. Map từng phần tử khi union các form.
  • Type “touched” với mọi key bắt buộc thế nào? { [K in keyof T]-?: boolean } hoặc Record<keyof T, boolean>.

Tóm tắt: T extends U ? X : Y cho library rẽ nhánh một type. Dùng khi một hàm có hai shape trả về thật, hoặc khi bạn infer để bóc. Đừng dùng trên trang Vue: overload hoặc hai hàm suy type tốt hơn.

Vì sao quan trọng: Người phỏng vấn muốn một chỗ rẽ production: hàm trả void hay { data: T } tùy T, hoặc bóc payload emit Vue. Họ không muốn bạn tự viết NonNullable trên bảng, trừ khi đang hỏi infer.

  1. Cách hoạt động: T extends U ? X : Y chọn X khi T gán được cho U. infer R đặt tên một mảnh của U để bạn trả về (Awaited, payload của event).
  2. T trần phân phối trên union: ToArray<string | number> thành string[] | number[]. Bọc [T] extends [U] khi không được phân phối.
  3. never extends mọi thứ. Tuple rỗng có thể biến generic thành never rồi khớp nhánh sai mà không cảnh báo. Default generic là unknown, không phải any, không thì nhánh sai chết.
  4. Nếu bạn cần infer hơn một lần trong code app, nhiều khả năng bạn muốn ReturnType, Parameters, hoặc Awaited. Ưu tiên chúng trong Vue SFC.
type ApiOk<T> = T extends void ? { ok: true } : { ok: true; data: T }

type Unwrap<T> = T extends Promise<infer R> ? Unwrap<R> : T

type ToArray<T> = T extends unknown ? T[] : never
type NoDistribute<T> = [T] extends [unknown] ? T[] : never

async function mutate<T>(path: string, body: unknown): Promise<ApiOk<T>> {
  const data = await api.post(path, body)
  // parse ở ranh giới; conditional type là contract của caller
  return (data === undefined ? { ok: true } : { ok: true, data }) as ApiOk<T>
}

type A = ToArray<string | number> // string[] | number[]
type B = NoDistribute<string | number> // (string | number)[]

Điều gì xảy ra: ApiOk<void> là { ok: true }. ApiOk<User> thêm data. ToArray phân phối, nên union thành union các mảng. NoDistribute bọc T trong tuple và trả một mảng của union. Unwrap là infer dùng như Awaited. as ApiOk<T> chỉ đúng nếu parser đã check data.

PatternDùngLưu ý
T extends U ? X : YHai shape trả về thậtĐừng dùng trên trang Vue
infer RBóc Promise / payloadƯu tiên ReturnType / Awaited trong code app
[T] extends [U]Ngăn phân phốiT trần phân phối có chủ đích
Default unknownGiữ cả hai nhánh sốngT = any làm nhánh sai chết

Góc nhìn senior

  • Dùng conditional khi một hàm có hai shape trả về thật, hoặc khi bạn infer để bóc. Đừng dùng trên trang app.
  • Phân phối trên union là tính năng. Bọc [T] extends [U] khi không được phân phối.
  • never từ [] khớp nhánh sai. Default generic là unknown, không phải any, không thì nhánh sai chết.
  • Overload thường rõ hơn return conditional, cả cho JSDoc lẫn suy type Vue.
  • Test case union bằng expectTypeOf, và vẫn parse ở ranh giới thay vì as ApiOk<T>. exactOptionalPropertyTypes khiến ? khác | undefined trên nhánh sai.

Lỗi hay gặp

  • Triệu chứng: ToArray<A | B> là A[] | B[] khi bạn muốn (A | B)[]. Nguyên nhân: phân phối. Cách xử lý: [T] extends [unknown].
  • Triệu chứng: nhánh sai không bao giờ khớp. Nguyên nhân: T = any. Cách xử lý: default unknown.
  • Triệu chứng: [] vào nhánh never. Nguyên nhân: never extends mọi thứ. Cách xử lý: ràng buộc hoặc bọc tuple.
  • Triệu chứng: suy type Vue SFC chết. Nguyên nhân: return conditional trong composable. Cách xử lý: hai hàm hoặc overload.

Nhà tuyển dụng hay hỏi gì

Một chỗ rẽ production: hàm trả void hay { data: T } tùy T, hoặc bóc payload emit của Vue. Họ không muốn bạn tự viết NonNullable trên bảng, trừ khi họ đang hỏi infer.

Trả lời khi phỏng vấn

Mình dùng conditional type khi một hàm có hai shape trả về thật, hoặc khi infer để bóc như Awaited. Mình không dùng trên trang Vue: overload hoặc hai hàm suy type tốt hơn. Phân phối trên union là tính năng; mình bọc T trong tuple khi không được phân phối. never từ list rỗng khớp nhánh sai, nên mình default generic là unknown, không phải any, không thì nhánh sai chết. Nếu cần infer hơn một lần trong code app thì mình muốn ReturnType hoặc Awaited. Mình test case union bằng expectTypeOf, và vẫn parse ở ranh giới thay vì assert ApiOk. Ở sản phẩm admin, mutate trả ok với data tùy chọn, parser chứ không phải assertion mới ra được T.

Nhớ nhanh

  • Phân phối có chủ đích, hoặc bọc [T] extends [U] để ngăn.
  • Ưu tiên ReturnType / Awaited hơn infer tự viết trong code app.
  • Default generic là unknown, không phải any, không thì nhánh sai chết.
  • Đừng để conditional lên trang Vue. Hai hàm hoặc overload suy type tốt hơn.

Câu hỏi nối tiếp thường gặp

  • Khi nào conditional phân phối, và dừng thế nào? T trần phân phối trên union. Bọc [T] extends [U].
  • infer R khác ReturnType thế nào? Viết infer khi bạn bóc shape mà ReturnType không đặt tên được. Ưu tiên ReturnType trong code app.
  • Vì sao T extends undefined khớp nhầm trên generic không ràng buộc? Generic chưa phải undefined. Ràng buộc hoặc default unknown.
  • Overload khác return conditional trong Vue script setup? Overload và hai hàm suy type ổn hơn return conditional.

Tóm tắt: Template literal type nối các string literal để emit('update:modelValue') không emit được field ngoài prop. Một ví dụ production là đủ: update:${keyof props} hoặc on${Capitalize<K>}.

Vì sao quan trọng: Tên event, key i18n, hoặc CSS var phải khớp một nguồn sự thật. event: string trên bus vẫn compile user:created khác userCreated. Chúng nổ tổ hợp nếu bạn interpolate string. Đổi tên prop rồi nhìn emit fail trong tsc.

  1. Cách hoạt động: `${K}` nối literal. Phải có as const trên object nguồn, không thì bạn interpolate string. keyof T & string bỏ number | symbol.
  2. Uppercase / Capitalize dành cho tên kiểu protocol (onChange), không phải chữ hiển thị. Chuỗi UI thuộc i18n.
  3. Path `/users/${string}` không validate một id. Param branded vẫn phải nằm ở đối số của hàm.
  4. Union template khổng lồ làm chậm tsc. Tách theo feature. Đừng type cả router bằng template khi Vue Router 4 RouteMap hoặc Nuxt typedPages đã có.
type UpdateEmits<T> = {
  [K in keyof T & string as `update:${K}`]: [value: T[K]]
}

type UserForm = { name: string; email: string }
type UserFormEmits = UpdateEmits<UserForm>
// các key: 'update:name' | 'update:email'

const ROUTES = {
  home: '/',
  user: '/users/:id',
} as const

type RouteName = keyof typeof ROUTES
function go(name: RouteName) {
  return ROUTES[name]
}
// go('Home') là lỗi type: không phải 'update:Name', không phải 'change:name'

Điều gì xảy ra: UpdateEmits remap mỗi key form thành update:${K} với payload khớp. Đổi email thành mail thì listener vỡ trong tsc. ROUTES cần as const, không thì go nhận string. Template path không phải chỗ kiểm id.

Nhu cầuViếtBỏ
Emit v-model có typeupdate:${keyof T & string}event: string trên bus
Tên routeas const + keyof typeofUnion template URL viết tay
Tên protocolon${Capitalize<K>}Copy giao diện / email

Góc nhìn senior

  • Một ví dụ là đủ: emit có type hoặc key i18n. Đừng type cả router bằng template khi Vue Router hoặc Nuxt đã có helper.
  • as const trên nguồn, không thì interpolate string chẳng được gì.
  • Union template khổng lồ làm chậm tsc. Tách theo feature. HTTP × Route thì ổn; string × string vẫn là string.
  • ${string}@${string} là type email yếu. Parse thì hơn.
  • Type key provide/inject cùng cách để magic string không đụng nhau. Đổi tên prop rồi nhìn emit fail. Ở sản phẩm admin, emit form user là UpdateEmits của type form, nên đổi email là parent gãy trong vue-tsc.

Lỗi hay gặp

  • Triệu chứng: emit('update:modelvalue') vẫn compile. Nguyên nhân: event: string. Cách xử lý: mapped update:${K}.
  • Triệu chứng: key interpolate thành string. Nguyên nhân: thiếu as const hoặc T & string. Cách xử lý: cả hai.
  • Triệu chứng: tsc chậm sau union path. Nguyên nhân: template tổ hợp. Cách xử lý: tách theo feature; tin router.
  • Triệu chứng: `/users/${string}` nhận number. Nguyên nhân: đó không phải check id. Cách xử lý: param branded.

Nhà tuyển dụng hay hỏi gì

Tên event, key i18n, hoặc CSS var phải khớp một nguồn sự thật. Một ví dụ là đủ: update:${keyof props} hoặc on${Capitalize<K>}.

Trả lời khi phỏng vấn

Mình dùng template literal để emit update-colon-modelValue không emit typo hay field không phải prop. Object nguồn cần as const, không thì mình interpolate string chẳng được gì. Mình không type cả router bằng template khi Vue Router hoặc Nuxt đã có helper có type. Union template khổng lồ làm chậm tsc, nên mình tách theo feature. Path slash-users-slash-string không validate id: đó vẫn là param branded. Capitalize dành cho tên protocol như onChange, không phải copy giao diện. Ở sản phẩm admin, emit form user là UpdateEmits của type form, nên đổi email là parent gãy trong vue-tsc.

Nhớ nhanh

  • as const trên nguồn, không thì bạn interpolate string.
  • Ưu tiên helper có type của router hơn union path viết tay.
  • Union template khổng lồ làm chậm tsc. Tách theo feature.
  • Template path không phải check id. Brand param.

Câu hỏi nối tiếp thường gặp

  • Vì sao as const? Không có thì value là string và nối chẳng được gì.
  • Khi nào không type router kiểu này? Vue Router RouteMap và Nuxt typedPages đã có.
  • Vì sao ${string}@${string} là type email yếu? Nó nhận gần như mọi chuỗi. Parse thì hơn.
  • Giữ key provide/inject khỏi đụng thế nào? Một type template hoặc branded dùng chung hai phía.

Tóm tắt: Brand ID hay bị trộn để getUser(orderId) thành lỗi type. Ra ngoài vẫn là string. Parse ở biên; as UserId chỉ sống trong parser đó.

Vì sao quan trọng: getUser(orderId) compile vì cả hai đều là string. Đó là sự cố production. Người phỏng vấn muốn lỗi type mà không cần object wrapper lúc runtime.

  1. Cách hoạt động: type UserId = string & { readonly __brand: 'UserId' }. Brand bị xóa lúc runtime. Hai brand dùng chung __brand: string có thể gán nhầm cho nhau: dùng literal độc nhất.
  2. Ở biên (route param, JSON), parseUserId trả UserId. Trong app, getUser chỉ nhận UserId. Zod .brand<'UserId'>() là bản mình ship.
  3. Đừng brand mọi string ngay từ đầu (Email, Url). Brand ID hay bị trộn: UserId, OrgId, OrderId.
  4. Vue route param vẫn string đến khi parent route parse một lần. Đừng as xuyên cây component. Giữ collection generic (Set<UserId>).
type UserId = string & { readonly __brand: 'UserId' }
type OrderId = string & { readonly __brand: 'OrderId' }

function parseUserId(raw: string): UserId {
  if (!raw.startsWith('usr_')) throw new Error('bad user id')
  return raw as UserId // chỉ trong parser
}

function getUser(id: UserId) {
  return api.get('/users/' + id)
}

declare const orderId: OrderId
// getUser(orderId) // lỗi: đúng là cả tính năng

const fromRoute = parseUserId(route.params.id as string)
getUser(fromRoute)

Điều gì xảy ra: UserId và OrderId đều là string lúc runtime, nên JSON và URL vẫn là string. getUser(orderId) là lỗi type. as UserId chỉ được phép trong parseUserId. Parent route parse một lần; child nhận UserId.

ChỗTypeQuy tắc
JSON / route paramstringParse một lần ở biên
Hàm trong appUserIdKhông as ở chỗ gọi
Zod schema.brand<'UserId'>()Ưu tiên khi đã parse rồi

Góc nhìn senior

  • Brand ID hay bị trộn. Đừng brand mọi email ngay từ đầu.
  • Brand bị xóa. as UserId cạnh JSON.parse đưa lại bug cũ.
  • Class với field private là brand lúc runtime (instanceof được) nhưng JSON thì đau. FE bám type brand kèm parse.
  • UserId | OrderId bị nới trong Set<string> nếu bạn .add(id) vào set string. Giữ collection generic.
  • Template literal /users/${id} nới thành string. Ổn nếu hàm fetch vẫn nhận UserId. Ở sản phẩm admin, getUser từ chối order.id trong type test, và trang list nhận UserId từ parent route.

Lỗi hay gặp

  • Triệu chứng: getUser(orderId) compile. Nguyên nhân: cả hai là string. Cách xử lý: brand với literal độc nhất.
  • Triệu chứng: as UserId cạnh JSON.parse. Nguyên nhân: bỏ parser. Cách xử lý: chỉ parseUserId.
  • Triệu chứng: hai brand gán được cho nhau. Nguyên nhân: __brand: string. Cách xử lý: literal độc nhất 'UserId' khác 'OrderId'.
  • Triệu chứng: mọi child đều as route param. Nguyên nhân: không parse ở parent. Cách xử lý: parse một lần.

Nhà tuyển dụng hay hỏi gì

getUser(orderId) compile vì cả hai đều là string. Sự cố production. Làm sao thành lỗi type mà không cần object wrapper lúc runtime?

Trả lời khi phỏng vấn

getUser của orderId compile được chính là sự cố: cả hai đều là string. Mình brand ID hay bị trộn: UserId là string giao với brand literal readonly. Ra ngoài vẫn là string; brand bị xóa, nên as UserId chỉ được phép trong parseUserId. Mình ship Zod brand khi bọn mình đã parse. Mình không brand mọi email ngay từ đầu, và không as xuyên cây Vue: parent route parse một lần. Collection giữ generic để Set của UserId không nhận OrderId. Ở sản phẩm admin, getUser từ chối order.id trong type test, và trang list nhận UserId từ parent route.

Nhớ nhanh

  • Brand ID hay bị trộn, không phải mọi string ngay từ đầu.
  • as UserId sống trong parseUserId, không cạnh JSON.parse.
  • Hai brand dùng chung __brand: string có thể gán nhầm cho nhau.
  • Parse Vue route param một lần ở parent. Đừng as xuyên cây.

Câu hỏi nối tiếp thường gặp

  • Vì sao không class UserId? instanceof lúc runtime được, nhưng JSON và Vue props thì đau. Type brand kèm parse là mặc định FE.
  • Zod .brand khác intersection viết tay thế nào? Cùng ý. Ưu tiên Zod khi đã parse.
  • Vue route param thành UserId thế nào? Parse ở parent route. Child nhận UserId, không phải string.
  • UserId vào Set<string> được không? Được, và nó bị nới. Giữ Set<UserId>.

Tóm tắt: satisfies kiểm value với một type mà không nới inferred type của value. Ghép as const satisfies T ở biên config và catalog để typeof routes.home vẫn '/'.

Vì sao quan trọng: as const làm object routes hẹp quá, không gán được chỗ cần Record<string, string>, hoặc as Record<…> nới value và mất key literal. Người phỏng vấn muốn biết satisfies đổi gì.

  1. Cách hoạt động: Value được kiểm với T và giữ inferred type của chính nó. as const satisfies T cho cả literal lẫn phép kiểm đủ. TS 4.9+: nâng version, đừng polyfill bằng double assertion.
  2. as const một mình không chứng minh mọi key đều có. as Record<…> nới literal. Key thừa vẫn được phép trừ khi type cấm.
  3. satisfies Record<string, boolean> không bắt tên flag gõ sai. satisfies Record<KnownFlag, boolean> thì có. Hoặc đảo: as const satisfies rồi suy KnownFlag từ object.
  4. Chỉ kiểm lúc compile. JSON remote vẫn unknown. Đừng satisfies kết quả JSON.parse. Đừng dùng trên defineProps.
const flags = {
  newCheckout: false,
  betaNav: true,
} as const satisfies Record<string, boolean>

type Flag = keyof typeof flags // 'newCheckout' | 'betaNav'

const routes = {
  home: '/',
  users: '/users',
} as const satisfies Record<string, `/${string}`>

function go(name: keyof typeof routes) {
  return routes[name] // typeof routes.home là '/'
}

// flags.typo = true // lỗi: 'typo' không phải Flag

Điều gì xảy ra: flags phải là object các boolean, nhưng Flag vẫn là union key literal. routes.home vẫn '/', không phải string. Key gõ sai là lỗi type. Thêm key bắt buộc vào contract thì satisfies fail ngay tại object.

Công cụGiữ literalChứng minh key
as constCóKhông kiểm đủ
as Record<…>Không: bị nớiNói dối về value
satisfies TCóCó, so với T
as const satisfies TCó, sâu nhấtCó, cặp hay dùng

Góc nhìn senior

  • Dùng satisfies cho route map, design token, và bảng feature flag ở ranh giới module. Không phải mọi object.
  • Nó không deep-freeze lúc runtime. JSON remote vẫn là unknown; phải parse.
  • Vue SFC: satisfies trên argument của defineProps là sai tool. Dùng defineProps<T>().
  • satisfies Record<string, boolean> không bắt tên flag gõ sai. Suy Flag từ object, hoặc dùng Record với key đã biết.
  • Thêm key bắt buộc vào contract rồi xem satisfies fail. Đổi tên value rồi xem chỗ gọi vẫn thấy literal. Ở admin nội bộ, bảng feature flag là as const satisfies, còn Flag là keyof typeof flags.

Lỗi hay gặp

  • Triệu chứng: typeof routes.home là string. Nguyên nhân: as Record<string, string>. Cách xử lý: as const satisfies.
  • Triệu chứng: flag gõ sai vẫn compile. Nguyên nhân: Record<string, boolean>. Cách xử lý: suy key từ object.
  • Triệu chứng: satisfies trên JSON.parse. Nguyên nhân: tưởng nó parse. Cách xử lý: unknown kèm schema.
  • Triệu chứng: dùng trên defineProps. Nguyên nhân: sai tool. Cách xử lý: defineProps<T>().

Người phỏng vấn hỏi gì

as const làm object routes hẹp quá, không gán được chỗ cần Record<string, string>, hoặc as Record<…> nới value và mất key literal. satisfies đổi được gì?

Trả lời khi phỏng vấn

satisfies kiểm value với một type mà không nới. Mình dùng cho route map, token, và bảng flag để typeof routes.home vẫn là slash, trong khi object vẫn khớp contract. as const không đủ để chứng minh mọi key có mặt; as Record nới literal, tệ hơn. Nó chỉ lúc compile. JSON remote vẫn unknown, nên mình không satisfies kết quả parse. Mình ghép as const satisfies T ở ranh giới module, không phải mọi object, và không dùng trên defineProps. Ở admin nội bộ, bảng feature flag là as const satisfies, Flag là keyof typeof flags, nên tên gõ sai thì vue-tsc fail.

Nhớ nhanh

  • satisfies kiểm mà không nới; as Record làm mất literal.
  • Ghép as const satisfies T ở ranh giới config và catalog.
  • JSON remote vẫn unknown. satisfies không parse.
  • Đừng dùng satisfies trên defineProps. Dùng defineProps<T>().

Câu hỏi nối tiếp thường gặp

  • as const khác satisfies thế nào? as const thì narrow. satisfies chứng minh contract mà không nới. Ghép hai cái.
  • Vì sao satisfies không giúp ở IndexedDB / postMessage? Data đó là unknown. Phải parse.
  • Nó freeze lúc runtime? Không. Chỉ lúc compile.
  • Bắt tên flag gõ sai thế nào? Suy Flag từ object, đừng dùng Record<string, boolean>.

Tóm tắt: unknown nghĩa là bạn phải narrow. any lây mọi expression nó đụng. JSON, catch, postMessage, và localStorage bắt đầu là unknown, rồi mới parse.

Vì sao quan trọng: data: any từ JSON.parse, axios, event.data, IndexedDB, postMessage. Người phỏng vấn muốn vì sao unknown là mặc định senior, và khi nào any là ngoại lệ có cân nhắc.

  1. Cách hoạt động: unknown gần như không gán được cho gì đến khi bạn narrow. any gán được mọi chỗ và đầu độc bốn mươi dòng sau. never là “chuyện này không xảy ra”.
  2. JSON.parse trả any trong lib.dom: bọc lại để trả unknown. Generic default <T = any> phá inference; default là unknown. any[] đi qua map vẫn là any.
  3. Chỉ cho any ở lối thoát đã ghi chú: .d.ts hỏng bạn sẽ xóa, hoặc eslint-disable kèm ticket. Sau parse, Vue ref là User, không phải unknown.
  4. object gần như không phải thứ bạn muốn (typeof null === 'object'). Ưu tiên unknown rồi narrow. Viết ref<User | null>(null) để Vue không suy ra any.
function parseJson(raw: string): unknown {
  return JSON.parse(raw) // lib.dom trả any: hãy bọc
}

window.onmessage = (event: MessageEvent<unknown>) => {
  const user = UserSchema.parse(event.data)
  inbox.value = user
}

try {
  await reload()
} catch (e: unknown) {
  if (e instanceof Error) toast(e.message)
}

const selected = ref<User | null>(null) // không phải ref() + null → any

Điều gì xảy ra: parseJson chặn JSON.parse rò any. MessageEvent<unknown> buộc parse trước khi inbox nhận User. catch (e: unknown) khớp useUnknownInCatchVariables. ref<User | null>(null) để Vue không suy ra any.

TypeNghĩaỞ biên
unknownPhải narrowMặc định
anyTắt checkerChỉ ngoại lệ có ticket
neverKhông thể xảy raLeftover của exhaustive switch
objectKhông phải primitive; null vẫn là objectƯu tiên unknown

Góc nhìn senior

  • unknown là mặc định ở mọi ranh giới tin cậy. any là lỗi, trừ khi bạn đang migrate với allowlist đang thu hẹp.
  • Một axios response.data: any biến bốn mươi dòng sau thành chương trình không còn type. Bọc client một lần.
  • unknown không phải check lúc runtime. Bạn vẫn phải viết guard hoặc schema.
  • noImplicitAny kèm lint ban vẫn chưa đủ: implicit any từ file JS với allowJs vẫn rò.
  • any tạm trên widget third-party cần tiêu chí thoát. never dành cho exhaustive switch và hàm luôn throw, không phải default của mảng rỗng. Ở admin nội bộ, MessageEvent là unknown và User đến từ UserSchema.parse.

Lỗi hay gặp

  • Triệu chứng: bốn mươi dòng không type sau fetch. Nguyên nhân: response.data: any. Cách xử lý: bọc unknown rồi parse.
  • Triệu chứng: ref() của null là any. Nguyên nhân: Vue inference. Cách xử lý: ref<User | null>(null).
  • Triệu chứng: catch (e) là any. Nguyên nhân: tắt flag. Cách xử lý: useUnknownInCatchVariables.
  • Triệu chứng: as User trên JSON.parse. Nguyên nhân: bỏ parser. Cách xử lý: schema.

Người phỏng vấn hỏi gì

data: any từ JSON.parse, axios, event.data, IndexedDB, postMessage. Vì sao unknown là mặc định của senior, và khi nào any là ngoại lệ có cân nhắc?

Trả lời khi phỏng vấn

unknown nghĩa là mình phải narrow; any lây mọi expression nó đụng. JSON, catch, postMessage, và localStorage bắt đầu là unknown rồi đi qua parser. Mình chỉ cho any sau một ticket mình sẽ xóa. Một axios response.data type any biến bốn mươi dòng sau thành chương trình không còn type. Mình bọc JSON.parse, default generic là unknown, và viết ref User hoặc null để Vue không suy ra any. never là leftover của exhaustive switch, không phải default cho list rỗng. object gần như không phải thứ mình muốn vì typeof null vẫn là object. Ở admin nội bộ, MessageEvent là unknown, User đến từ UserSchema.parse, còn catch dùng unknown.

Nhớ nhanh

  • unknown là mặc định ở mọi ranh giới tin cậy.
  • Một any lây các expression sau. Bọc JSON.parse.
  • noImplicitAny kèm lint ban; implicit any từ file JS vẫn rò.
  • never là leftover của exhaustive switch, không phải default mảng rỗng.

Câu hỏi nối tiếp thường gặp

  • Vì sao unknown gần như không gán được, còn any thì mọi chỗ? unknown buộc phải narrow. any tắt checker.
  • Type window.onmessage không any thế nào? MessageEvent<unknown>, rồi parse event.data.
  • Lint ban any đã đủ chưa? Chưa. Implicit any từ allowJs vẫn rò.
  • unknown khác Record<string, unknown> cho JSON thế nào? Ưu tiên unknown, rồi parse. Record vẫn nói dối về key.

Tóm tắt: strict: true và isolatedModules: true là sàn cho app Vue 3 + Vite. Đo bằng vue-tsc --noEmit và strictTemplates, không chỉ tsc.

Vì sao quan trọng: Người phỏng vấn hỏi tsconfig của bạn cho app Vue 3 + Vite có gì, và flag nào không thương lượng năm 2026. Họ muốn biết bạn đã ship strict hay mới chỉ nói miệng.

  1. Cách hoạt động: strict bật strictNullChecks, noImplicitAny, và strictFunctionTypes. Vite transpile từng file một, nên cần isolatedModules.
  2. const enum thành undefined lúc runtime với Vite: hãy dùng union. Cũng nên có noUncheckedIndexedAccess, useUnknownInCatchVariables, verbatimModuleSyntax. skipLibCheck là yes thực dụng cho repo app.
  3. vue-tsc --noEmit type-check .vue. tsc thì không. strictTemplates mới bắt props Vue sai. Nhiều team không bao giờ bật.
  4. Path alias (@/) phải khớp Vite và tsconfig. Hai nguồn sự thật là “IDE chạy được, build thì fail”. Đừng đánh nhau với tsconfig Nuxt generate; hãy extend.
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "useUnknownInCatchVariables": true,
    "noImplicitOverride": true,
    "verbatimModuleSyntax": true,
    "isolatedModules": true,
    "skipLibCheck": true
  },
  "vueCompilerOptions": {
    "strictTemplates": true
  }
}

Điều gì xảy ra: Mọi flag trong snippet này đều là thứ mình sẽ ship. isolatedModules là lý do cấm const enum. strictTemplates là lý do prop sai trên child fail CI. CI chạy vue-tsc --noEmit, không chỉ tsc, nên SFC cũng được check.

FlagVì saoLưu ý
strictSàn: null, any, hàmChỉ là diễn nếu any khắp nơi
isolatedModulesVite transpile từng file riêngCấm const enum
noUncheckedIndexedAccessrows[0] là T | undefinedMột tuần ?. trên app lớn
verbatimModuleSyntaxLoại import tường minhImport chỉ type không tạo cycle

Góc nhìn senior

  • strict: true mà any khắp nơi chỉ là diễn. Kèm lint ban.
  • Bật noUncheckedIndexedAccess trên app 200k dòng là một tuần ?.. Làm package mới trước; đừng giả vờ.
  • verbatimModuleSyntax bắt loại import phải tường minh, nên import chỉ type không thành cycle lúc runtime.
  • Project references hay một config lớn là lựa chọn scale, không phải chuyện đạo đức. exactOptionalPropertyTypes nếu team chịu nổi.
  • target / lib quá cũ: không có type AbortSignal.any. Path alias phải khớp Vite, không thì IDE chạy mà build fail. Trên sản phẩm admin, CI chạy vue-tsc, prop con sai thì fail pull request.

Lỗi hay gặp

  • Triệu chứng: const enum là undefined trên trình duyệt. Nguyên nhân: Vite + transpile từng file. Cách xử lý: union.
  • Triệu chứng: CI xanh, props Vue sai. Nguyên nhân: chỉ tsc. Cách xử lý: vue-tsc + strictTemplates.
  • Triệu chứng: @/ IDE chạy được, build fail. Nguyên nhân: alias lệch. Cách xử lý: một nguồn duy nhất.
  • Triệu chứng: strict bật, any khắp nơi. Nguyên nhân: diễn. Cách xử lý: lint ban + bọc client.

Nhà tuyển dụng hay hỏi gì

App Vue 3 + Vite thì tsconfig của bạn có gì, và flag nào không thương lượng được năm 2026? Họ đang xem bạn đã ship strict hay chỉ nói suông.

Trả lời khi phỏng vấn

strict true và isolatedModules true là sàn cho Vue 3 kèm Vite. Vite transpile từng file riêng, nên const enum thành undefined lúc runtime và mình dùng union. Mình cũng muốn noUncheckedIndexedAccess, useUnknownInCatchVariables, và verbatimModuleSyntax. Mình đo bằng vue-tsc noEmit và strictTemplates, không chỉ tsc. Bật kiểm index trên app 200k dòng là một tuần optional chaining; mình làm package mới trước, không giả vờ. Path alias phải khớp Vite, không thì IDE chạy mà build fail. skipLibCheck là yes thực dụng cho repo app. Trên sản phẩm admin, CI chạy vue-tsc, prop con sai thì fail pull request.

Nhớ nhanh

  • vue-tsc --noEmit type-check .vue; tsc thì không.
  • isolatedModules cấm const enum. Union vẫn chạy được với Vite.
  • strictTemplates mới bắt được props Vue sai.
  • strict mà any khắp nơi chỉ là diễn. Kèm lint ban.

Câu hỏi nối tiếp thường gặp

  • Vì sao isolatedModules cấm const enum? Mỗi file được transpile riêng, nên object enum không tồn tại lúc runtime.
  • vue-tsc khác tsc thế nào? Chỉ vue-tsc type-check file .vue.
  • noUncheckedIndexedAccess đổi gì? rows[0] và Map.get thành T | undefined.
  • verbatimModuleSyntax và import type giúp gì? Import chỉ type không thành cycle lúc runtime.

Tóm tắt: Type compiler macro Vue 3.3+: defineProps<T>() và defineEmits<{ select: [id: UserId] }>(), rồi để vue-tsc fail parent khi userId sai hoặc thiếu listener.

Vì sao quan trọng: Type component này: v-model, userId branded, variant discriminated, và emit mà parent phải xử lý. So nhanh với React props. Không dùng object defineProps runtime chưa type.

  1. Cách hoạt động: defineProps<T>() và defineEmits<{ change: [id: UserId] }>(). v-model là modelValue kèm 'update:modelValue'. Default để trong withDefaults.
  2. defineModel<UserId>() là lối tắt 3.4+; khi truyền đi vẫn là modelValue. Props một chiều; mutate một field của prop vẫn type-check được, nhưng vẫn là bug Vue.
  3. emits chưa khai báo đi tiếp như event native / vnode attrs, và listener phía parent không có type. Đừng type emit kiểu (e: string, ...args: any[]).
  4. React gộp emit vào callback onChange. Vue tách props và emit, nên type của v-model là chuyện riêng. Parse UserId branded ở route phía parent, không as trong con.
const props = withDefaults(
  defineProps<{
    userId: UserId
    variant: 'idle' | 'loading' | 'success' | 'error'
    modelValue: string
    disabled?: boolean
  }>(),
  { disabled: false },
)

const emit = defineEmits<{
  'update:modelValue': [value: string]
  select: [id: UserId]
}>()

function onSelect() {
  emit('select', props.userId)
}
// emit('select', 'raw-string') // lỗi nếu UserId là branded

Điều gì xảy ra: Parent phải truyền userId branded và tag variant. v-model là modelValue kèm emit update. select cần payload UserId, nên chuỗi thô sẽ fail. Default nằm trong withDefaults, không phải object props runtime.

CầnVueTương đương React
PropsdefineProps<T>()Một type object props
EmitsdefineEmits<{ ev: [T] }>()onChange: (v: T) => void
v-modelmodelValue + emit update / defineModelValue controlled + onChange
Attrs / refinheritAttrs: false + $attrs có typeComponentProps<'button'> + forwardRef

Góc nhìn senior

  • Trong app chỉ TS, type chính là contract. Runtime props: { userId: String } dành cho custom element và chỗ chỉ dùng JS.
  • Mutate props.user.role vẫn type-check được, nhưng là bug Vue. Truyền primitive hoặc emit.
  • Generic component (generic="T" từ 3.3+) là cách Select suy type của option. Đừng gán any cho option.
  • Hydration: default props dùng Date.now() hoặc Math.random() trong factory sẽ lệch SSR.
  • vue-tsc trên parent phải fail khi userId sai hoặc thiếu listener. Parse brand ở route, không trong con. Trên sản phẩm admin, chip user chính là component đó, thiếu listener select thì CI fail.

Lỗi hay gặp

  • Triệu chứng: emit('update:modelValue') không có payload. Nguyên nhân: emits chưa khai báo. Cách xử lý: defineEmits có type.
  • Triệu chứng: mọi parent đều as userId. Nguyên nhân: prop type string. Cách xử lý: UserId branded, parse ở route.
  • Triệu chứng: mutate props.user.role “chạy được”. Nguyên nhân: object prop là reference. Cách xử lý: primitive hoặc emit.
  • Triệu chứng: runtime defineProps({ userId: String }) trong app TS. Nguyên nhân: thói quen cũ. Cách xử lý: defineProps<T>().

Nhà tuyển dụng hay hỏi gì

Type component này: v-model, userId branded, variant discriminated, và emit mà parent phải xử lý. So nhanh với React props. Không dùng object defineProps runtime chưa type.

Trả lời khi phỏng vấn

Mình type macro Vue 3.3: defineProps T và defineEmits với select nhận tuple UserId. v-model là modelValue kèm update-colon-modelValue; defineModel là sugar, trên wire vẫn là modelValue. Trong app chỉ TS, mình không ship object props runtime chưa type. Mutate props.user.role vẫn type-check được, nhưng là bug Vue: mình truyền primitive hoặc emit. React gộp emit vào callback; Vue tách chúng, nên type v-model là chuyện riêng. vue-tsc trên parent phải fail khi userId sai hoặc thiếu listener. Mình parse UserId ở route, không as trong con. Trên sản phẩm admin, chip user chính là component đó, thiếu listener select thì CI fail.

Nhớ nhanh

  • defineProps<T>() + defineEmits<{ ev: [payload] }>(): không phải (e: string, ...args: any[]).
  • Mutate một field của prop vẫn type-check được, nhưng vẫn là bug Vue lúc runtime.
  • vue-tsc trên parent phải fail khi userId sai hoặc thiếu listener.
  • v-model là modelValue kèm emit update. defineModel là sugar.

Câu hỏi nối tiếp thường gặp

  • Props runtime khác type-only? Runtime String / Boolean cho custom element và chỗ chỉ dùng JS. App chỉ TS dùng defineProps<T>().
  • Generic component? generic="T" trên SFC để Select suy type option.
  • So với React? Object props kèm callback onChange. Vue tách props và emit, nên v-model là chuyện riêng.
  • Bẫy default SSR? Date.now() hoặc Math.random() trong withDefaults làm lệch hydration.