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.
- Cách hoạt động: Cả hai mô tả shape object.
typecòn alias được union, intersection, mapped type, hoặc primitive.interfacekhông đặt tên được union. interfacedeclaration merging: haiinterface Usercùng scope thành một. Đó là mục đích củaWindow, module augmentation Vue, và Express.extendstrên interface thì fail rõ khi xung đột. IntersectionA & Bcó thể kéo field vềneverthầm hơn.- 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ần | Chọn | Vì sao |
|---|---|---|
| Union / brand / mapped alias | type | interface không đặt tên được |
Augment Window / Vue | interface | Declaration merging là tính năng |
| DTO object trong code app | Cả hai; theo repo | Style lẫn tốn hơn keyword |
| Field xung đột | interface extends | Fail rõ vs & → never |
Góc nhìn senior
- Mặc định team:
typecho union và alias,interfacecho merge. Shape object theo file bạn đang ở. - Hai file
interface Usermerge không cảnh báo. Đó là tính năng choWindowvà bẫy cho DTO. - Mình thà
extendsfail rõ còn hơn intersection sập thànhnever. - 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.analyticsgiữ interface trongenv.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ý:typecho union.- Triệu chứng: một field biến mất lúc review. Nguyên nhân:
interface Userkhác đã merge. Cách xử lý: aliastypecho DTO, hoặc một interface mỗi tên.- Triệu chứng:
A & Blàm field thànhnever. Nguyên nhân: intersection không tương thích. Cách xử lý:extendshoặc union.- Triệu chứng: Vue
declare moduleviết lại thànhtype. 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
typecho union, brand, alias mapped.interfacecho declaration merging.- Hai file
interface Usermerge thầm. Đó là bẫy DTO. - Theo repo cho shape object. Style lẫn tốn hơn keyword.
- Augmentation Vue /
Windowgiữ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, VueComponentCustomProperties, ExpressRequest. - Vì sao
A & Bthànhnever? Field không tương thích.extendsfail sớm hơn. - Mặc định team trên repo Vue greenfield?
typecho union và DTO;interfacechỉ 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.
- Cách hoạt động:
<T>khai báo lúc gọi (hoặc trên type alias). TypeScript suy raTtừ đối số khi được. Nếu mọi caller viết<Foo>, inference đã thất bại. T extends { id: string }ràng buộc những gì thân hàm dùng được. Đừng generic “phòng khi”. NếuTchỉ xuất hiện một lần như annotation trả về, bạn muốn một type có tên.- Mặc định
unknownở ranh giới network để inference không thành{}. Parse (zod) hoặc truyềnparse: (data: unknown) => TđểTlà type kiếm được. - Composable Vue:
ref<T | null>(null)kèm parser. ReactuseQuery<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.
| Pattern | Khi nào | Smell |
|---|---|---|
parse: (unknown) => T | Ranh giới network / storage | useFetch<T>() không parse |
T extends { id: string } | Thân hàm cần một field | T không ràng buộc, không đọc |
| Không generic | Một domain type có tên | T 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{}hayany. - 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ắcT) chứ đừng ghi “xin hãy truyền type param”. - Zod / Valibot
z.infer<typeof schema>là cáchTđược kiếm ở ranh giới năm 2026. Ở sản phẩm admin, composable list nhận hàmparseđểTkiế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 objectvẫn không đọcid. 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ắcT. Cách xử lý: mộtparsehoặc giá trị mẫu.- Triệu chứng:
function wrap<T>(x: T): Tchỉ 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à
Tchỉ 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
Tchỉ dùng một lần làm return type. - Giữ inference mà không cần type argument thế nào? Đặt
Tvào một argument (parse, mẫu, schema). - Vì sao default
unknownchứ không phảiany?anybị leak.unknownbuộ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.
- Cách hoạt động: Sau
if (state.status === 'success'), TypeScript chỉ còn giữ variant đó. Tag literal (status) là discriminant. typeof/in/instanceof/ equality cũng narrow.instanceoffail khi qua iframe: hãy dùng mộtkinddo bạn định nghĩa cho giá trị đi xuyên realm.- Cờ boolean ổn cho nút một dòng, nhưng sai khi có retry hoặc stale-while-revalidate.
datastale là variant khác (error+datatùy chọn), không phảidata?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. 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.
| Check | Narrow thành | Lưu ý |
|---|---|---|
status === 'success' | Chỉ variant đó | Tag phải là literal, không phải string |
typeof x === 'string' | Primitive | typeof null vẫn 'object' |
in / instanceof | Object / class | instanceof fail khi khác realm |
assertNever | Union đủ nhánh | Template 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:
assertNevertrongswitch. Thêm variant phải làm vỡ build. - Template Vue làm narrowing không còn sound. Derive nhãn bằng
computedtrong script. - Narrowing không validate JSON. Parse trước (
unknown→User), rồi mới switchstatus. - Stale-while-revalidate là
errorkèmdatatùy chọn, hoặc tagsuccess+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ý: unionstatus.- Triệu chứng:
state.data.nametrong template lúc loading. Nguyên nhân: template mất narrowing. Cách xử lý:computedtrong script.- Triệu chứng:
instanceof Arrayfalse từ iframe. Nguyên nhân: realm. Cách xử lý:Array.isArray/ mộtkind.- 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ý:assertNevertrong 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
unknowntrước khi switch. - Template Vue mất narrowing. Gán nhãn variant trong script.
assertNeverbiếnstatusmớ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 && datavàerror && loadingđều compile. Một tag thì cấm cả hai. - Giữ bảng stale hiện khi error thế nào?
datatùy chọn trên varianterror, hoặc tagrefreshing. typeofcó narrownullkhông?typeof null === 'object'. Check== nulltrướ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.
- 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. Record<K, V>là index của các keyK.Record<string, V>giấu key thiếu.Record<Role, V>với union role mới hữu ích.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.Readonly<T>là lúc compile.Object.freezefreeze nông lúc runtime. Vuereadonly()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.
| Utility | Làm gì | Bẫy |
|---|---|---|
Partial<T> | Mọi key tùy chọn | PATCH dở: id? và secret thành field ghi |
Pick / Omit | Giữ / bỏ key nông | Secret lồng sống sót sau Omit |
Record<K, V> | Object các key K | Record<string, V> giấu key thiếu |
Readonly / Awaited | Cờ compile / bóc Promise | Khô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ả
Userthì DTO đã fail. Partial<User>biếnid?trong khi API vẫn bắt buộc, và mởpasswordHashnhư field ghi được.Omitchạy trên từng nhánh union và có thể làm mất union. DùngExcludekhi ý bạn là các member, không phải các key.- Đừng giữ ba bản
Userviết tay nếu một unionPicklà đủ. 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ằngUserPatchtừ chốipasswordHash.
Lỗi hay gặp
- Triệu chứng: PATCH làm trống
idhoặc gửipasswordHash. Nguyên nhân:Partial<User>. Cách xử lý:Partial<Pick<User, writable keys>>.- Triệu chứng:
passwordHintlồng vẫn còn trên type public. Nguyên nhân:Omitnô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
Uservẫ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ảiObject.freezevà không phải Vuereadonly().- DTO là type đối số HTTP. Nếu cả
Userlọ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ếnid?và mở secret như field ghi. ComposePartial<Pick<…>>. - Cho một secret lồng mà
Omitkhông gỡ.profile.passwordHintvẫn còn đến khi bạn gỡ object lồng. Readonly<T>vsObject.freezevs Vuereadonly()? Cờ compile, freeze nông, và proxy. Chỉ freeze và proxy mới throw hoặc no-op lúc runtime.Omitlàm gì với discriminated union? Nó chạy trên từng nhánh và có thể làm mất union. DùngExcludetrê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.
- 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. asserts x is Tphả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.- Ở ranh giới network hoặc storage, parse (
zod/ Valibot) rồi inferTtừ schema. Guard viết tay dành cho union của bạn (isAbortError,isSuccess). instanceoffail khi qua iframe và package bị nhân đôi. Ưu tiên discriminantkinddo bạn đặt. Narrow lại trong Vuewatchvà 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ào | Dối cần để ý |
|---|---|---|
Predicate x is T | Union của bạn | 'id' in x như x is User |
asserts x is T | Startup / bất biến | Thân hàm không throw |
| Parse schema | Network / storage | as User trên JSON |
Array.isArray | Mảng | Khô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
isAbortErrorvàisSuccess. - Hàm assertion phải throw, nên để lúc startup, không để trên đường render.
assertssai là crash chỉ thấy ở production. instanceoffail khi qua realm và khi bundler nhân đôi package. Ưu tiênkinddo bạn đặt.- Guard sai nhiễm sang phần còn lại của file, kể cả
noUncheckedIndexedAccess. Guard lại trong callbackwatch. Ở admin nội bộ,catchdùngunknownvàisAbortError, cònUserđế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ểmtypeof === 'object'. Cách xử lý: parse hoặc kiểmname.- Triệu chứng:
x is User[]sauArray.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 xkhông phảix is User. - Schema ở ranh giới network. Guard viết tay cho union của bạn.
assertskhông throw là crash chỉ thấy ở production.- Narrow lại trong
watchvà interceptor. Chúng mất phần đã narrow.
Câu hỏi nối tiếp thường gặp
- Vì sao
'id' in xchưa đủ chox 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)khiuseUnknownInCatchVariablesthế nào?elàunknown. Narrow bằngisAbortErrorhoặc parse. - Vì sao
instanceof ApiErrorfail giữa app và package dùng chung? Constructor bị nhân đôi. Ưu tiên discriminantkind.
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.
- Cách hoạt động:
{ [K in keyof T]: … }đi qua các key. Mapped type homomorphic giữreadonlyvà optional đến khi bạn viết lại key theo cách làm chúng bị reset. - Modifier:
+?/-?vàreadonly/-readonly.-?bắt buộc mọi key: đó là map “touched”Record<keyof T, boolean>. - Key remapping (
as) dành cho emitupdate:${K}và để bỏ key bằngnever. Nó thừa nếu chỉ đổiidthànhuserIdmột lần. 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ầu | Viết | Tránh |
|---|---|---|
| Map error form | Partial<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 key | as 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
Tphải làm form lỗi đến khi UI xử lý. Đó là lý do map trênkeyof 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:Khoặc bỏ key bằngnever, 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-fortrênObject.keystype key thànhstring: kèm helper có type.
Lỗi hay gặp
- Triệu chứng: field
phonemới không có chỗ error. Nguyên nhân: interface error viết tay. Cách xử lý: mapkeyof 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ôngas.
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
Tphả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ằngaskhi cần.
Câu hỏi nối tiếp thường gặp
- Vì sao không giữ interface
ProfileErrorssong song viết tay? Field mới sẽ không hiện. Mapkeyof T. - Bỏ key trong mapped type thế nào? Remap
as nevercho DTO public, hoặcOmitnế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ặcRecord<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.
- Cách hoạt động:
T extends U ? X : YchọnXkhiTgán được choU.infer Rđặt tên một mảnh củaUđể bạn trả về (Awaited, payload của event). Ttrần phân phối trên union:ToArray<string | number>thànhstring[] | number[]. Bọc[T] extends [U]khi không được phân phối.neverextends mọi thứ. Tuple rỗng có thể biến generic thànhneverrồi khớp nhánh sai mà không cảnh báo. Default generic làunknown, không phảiany, không thì nhánh sai chết.- Nếu bạn cần
inferhơn một lần trong code app, nhiều khả năng bạn muốnReturnType,Parameters, hoặcAwaited. Ư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.
| Pattern | Dùng | Lưu ý |
|---|---|---|
T extends U ? X : Y | Hai shape trả về thật | Đừng dùng trên trang Vue |
infer R | Bóc Promise / payload | Ưu tiên ReturnType / Awaited trong code app |
[T] extends [U] | Ngăn phân phối | T trần phân phối có chủ đích |
Default unknown | Giữ cả hai nhánh sống | T = 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. nevertừ[]khớp nhánh sai. Default generic làunknown, không phảiany, 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>.exactOptionalPropertyTypeskhiến?khác| undefinedtrê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ý: defaultunknown.- Triệu chứng:
[]vào nhánhnever. Nguyên nhân:neverextends 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/Awaitedhơninfertự viết trong code app. - Default generic là
unknown, không phảiany, 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?
Ttrần phân phối trên union. Bọc[T] extends [U]. infer RkhácReturnTypethế nào? Viếtinferkhi bạn bóc shape màReturnTypekhông đặt tên được. Ưu tiênReturnTypetrong code app.- Vì sao
T extends undefinedkhớp nhầm trên generic không ràng buộc? Generic chưa phảiundefined. Ràng buộc hoặc defaultunknown. - 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.
- Cách hoạt động:
`${K}`nối literal. Phải cóas consttrên object nguồn, không thì bạn interpolatestring.keyof T & stringbỏnumber | symbol. Uppercase/Capitalizedành cho tên kiểu protocol (onChange), không phải chữ hiển thị. Chuỗi UI thuộc i18n.- Path
`/users/${string}`không validate một id. Param branded vẫn phải nằm ở đối số của hàm. - Union template khổng lồ làm chậm
tsc. Tách theo feature. Đừng type cả router bằng template khi Vue Router 4RouteMaphoặc NuxttypedPagesđã 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ầu | Viết | Bỏ |
|---|---|---|
Emit v-model có type | update:${keyof T & string} | event: string trên bus |
| Tên route | as const + keyof typeof | Union template URL viết tay |
| Tên protocol | on${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 consttrên nguồn, không thì interpolatestringchẳng được gì.- Union template khổng lồ làm chậm
tsc. Tách theo feature.HTTP × Routethì ổn;string × stringvẫn làstring. ${string}@${string}là type email yếu. Parse thì hơn.- Type key
provide/injectcù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àUpdateEmitscủa type form, nên đổiemaillà parent gãy trongvue-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ý: mappedupdate:${K}.- Triệu chứng: key interpolate thành
string. Nguyên nhân: thiếuas consthoặcT & string. Cách xử lý: cả hai.- Triệu chứng:
tscchậ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 consttrên nguồn, không thì bạn interpolatestring.- Ư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àstringvà nối chẳng được gì. - Khi nào không type router kiểu này? Vue Router
RouteMapvà NuxttypedPagesđã 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/injectkhỏ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.
- Cách hoạt động:
type UserId = string & { readonly __brand: 'UserId' }. Brand bị xóa lúc runtime. Hai brand dùng chung__brand: stringcó thể gán nhầm cho nhau: dùng literal độc nhất. - Ở biên (route param, JSON),
parseUserIdtrảUserId. Trong app,getUserchỉ nhậnUserId. Zod.brand<'UserId'>()là bản mình ship. - Đừng brand mọi
stringngay từ đầu (Email,Url). Brand ID hay bị trộn:UserId,OrgId,OrderId. - Vue route param vẫn
stringđến khi parent route parse một lần. Đừngasxuyê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ỗ | Type | Quy tắc |
|---|---|---|
| JSON / route param | string | Parse một lần ở biên |
| Hàm trong app | UserId | Khô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 UserIdcạnhJSON.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 | OrderIdbị nới trongSet<string>nếu bạn.add(id)vào set string. Giữ collection generic.- Template literal
/users/${id}nới thànhstring. Ổn nếu hàm fetch vẫn nhậnUserId. Ở sản phẩm admin,getUsertừ chốiorder.idtrong type test, và trang list nhậnUserIdtừ 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 UserIdcạnhJSON.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
asroute 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
stringngay từ đầu. as UserIdsống trongparseUserId, không cạnhJSON.parse.- Hai brand dùng chung
__brand: stringcó thể gán nhầm cho nhau. - Parse Vue route param một lần ở parent. Đừng
asxuyên cây.
Câu hỏi nối tiếp thường gặp
- Vì sao không class
UserId?instanceoflúc runtime được, nhưng JSON và Vue props thì đau. Type brand kèm parse là mặc định FE. - Zod
.brandkhác intersection viết tay thế nào? Cùng ý. Ưu tiên Zod khi đã parse. - Vue route param thành
UserIdthế nào? Parse ở parent route. Child nhậnUserId, không phảistring. UserIdvàoSet<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ì.
- Cách hoạt động: Value được kiểm với
Tvà giữ inferred type của chính nó.as const satisfies Tcho cả literal lẫn phép kiểm đủ. TS 4.9+: nâng version, đừng polyfill bằng double assertion. as constmộ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.satisfies Record<string, boolean>không bắt tên flag gõ sai.satisfies Record<KnownFlag, boolean>thì có. Hoặc đảo:as const satisfiesrồi suyKnownFlagtừ object.- Chỉ kiểm lúc compile. JSON remote vẫn
unknown. Đừngsatisfieskết quảJSON.parse. Đừng dùng trêndefineProps.
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ữ literal | Chứng minh key |
|---|---|---|
as const | Có | Không kiểm đủ |
as Record<…> | Không: bị nới | Nói dối về value |
satisfies T | Có | Có, so với T |
as const satisfies T | Có, sâu nhất | Có, cặp hay dùng |
Góc nhìn senior
- Dùng
satisfiescho 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:
satisfiestrên argument củadefinePropslà sai tool. DùngdefineProps<T>(). satisfies Record<string, boolean>không bắt tên flag gõ sai. SuyFlagtừ object, hoặc dùngRecordvới key đã biết.- Thêm key bắt buộc vào contract rồi xem
satisfiesfail. Đổ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ònFlaglàkeyof typeof flags.
Lỗi hay gặp
- Triệu chứng:
typeof routes.homelà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:
satisfiestrênJSON.parse. Nguyên nhân: tưởng nó parse. Cách xử lý:unknownkè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
satisfieskiểm mà không nới;as Recordlàm mất literal.- Ghép
as const satisfies Tở ranh giới config và catalog. - JSON remote vẫn
unknown.satisfieskhông parse. - Đừng dùng
satisfiestrêndefineProps. DùngdefineProps<T>().
Câu hỏi nối tiếp thường gặp
as constkhácsatisfiesthế nào?as constthì narrow.satisfieschứng minh contract mà không nới. Ghép hai cái.- Vì sao
satisfieskhô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
Flagtừ object, đừng dùngRecord<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.
- Cách hoạt động:
unknowngần như không gán được cho gì đến khi bạn narrow.anygán được mọi chỗ và đầu độc bốn mươi dòng sau.neverlà “chuyện này không xảy ra”. JSON.parsetrảanytrong lib.dom: bọc lại để trảunknown. Generic default<T = any>phá inference; default làunknown.any[]đi quamapvẫn làany.- Chỉ cho
anyở lối thoát đã ghi chú:.d.tshỏng bạn sẽ xóa, hoặc eslint-disable kèm ticket. Sau parse, Vue ref làUser, không phảiunknown. objectgần như không phải thứ bạn muốn (typeof null === 'object'). Ưu tiênunknownrồi narrow. Viếtref<User | null>(null)để Vue không suy raany.
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.
| Type | Nghĩa | Ở biên |
|---|---|---|
unknown | Phải narrow | Mặc định |
any | Tắt checker | Chỉ ngoại lệ có ticket |
never | Không thể xảy ra | Leftover của exhaustive switch |
object | Không phải primitive; null vẫn là object | Ưu tiên unknown |
Góc nhìn senior
unknownlà mặc định ở mọi ranh giới tin cậy.anylà lỗi, trừ khi bạn đang migrate với allowlist đang thu hẹp.- Một axios
response.data: anybiế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. unknownkhông phải check lúc runtime. Bạn vẫn phải viết guard hoặc schema.noImplicitAnykèm lint ban vẫn chưa đủ: implicit any từ file JS vớiallowJsvẫn rò.anytạm trên widget third-party cần tiêu chí thoát.neverdà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ộ,MessageEventlàunknownvà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ọcunknownrồi parse.- Triệu chứng:
ref()củanulllà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 UsertrênJSON.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
unknownlà mặc định ở mọi ranh giới tin cậy.- Một
anylây các expression sau. BọcJSON.parse. noImplicitAnykèm lint ban; implicit any từ file JS vẫn rò.neverlà 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
unknowngần như không gán được, cònanythì mọi chỗ?unknownbuộc phải narrow.anytắt checker. - Type
window.onmessagekhônganythế nào?MessageEvent<unknown>, rồi parseevent.data. - Lint ban
anyđã đủ chưa? Chưa. Implicit any từallowJsvẫn rò. unknownkhácRecord<string, unknown>cho JSON thế nào? Ưu tiênunknown, 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.
- Cách hoạt động:
strictbậtstrictNullChecks,noImplicitAny, vàstrictFunctionTypes. Vite transpile từng file một, nên cầnisolatedModules. const enumthànhundefinedlúc runtime với Vite: hãy dùng union. Cũng nên cónoUncheckedIndexedAccess,useUnknownInCatchVariables,verbatimModuleSyntax.skipLibChecklà yes thực dụng cho repo app.vue-tsc --noEmittype-check.vue.tscthì không.strictTemplatesmới bắt props Vue sai. Nhiều team không bao giờ bật.- 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ớitsconfigNuxt 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.
| Flag | Vì sao | Lưu ý |
|---|---|---|
strict | Sàn: null, any, hàm | Chỉ là diễn nếu any khắp nơi |
isolatedModules | Vite transpile từng file riêng | Cấm const enum |
noUncheckedIndexedAccess | rows[0] là T | undefined | Một tuần ?. trên app lớn |
verbatimModuleSyntax | Loại import tường minh | Import chỉ type không tạo cycle |
Góc nhìn senior
strict: truemàanykhắp nơi chỉ là diễn. Kèm lint ban.- Bật
noUncheckedIndexedAccesstrên app 200k dòng là một tuần?.. Làm package mới trước; đừng giả vờ. verbatimModuleSyntaxbắ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.
exactOptionalPropertyTypesnếu team chịu nổi. target/libquá cũ: không có typeAbortSignal.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ạyvue-tsc, prop con sai thì fail pull request.
Lỗi hay gặp
- Triệu chứng:
const enumlàundefinedtrê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:
strictbật,anykhắ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 --noEmittype-check.vue;tscthì không.isolatedModulescấmconst enum. Union vẫn chạy được với Vite.strictTemplatesmới bắt được props Vue sai.strictmàanykhắ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
isolatedModulescấmconst enum? Mỗi file được transpile riêng, nên object enum không tồn tại lúc runtime. vue-tsckháctscthế nào? Chỉvue-tsctype-check file.vue.noUncheckedIndexedAccessđổi gì?rows[0]vàMap.getthànhT | undefined.verbatimModuleSyntaxvàimport typegiú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.
- Cách hoạt động:
defineProps<T>()vàdefineEmits<{ change: [id: UserId] }>().v-modellàmodelValuekèm'update:modelValue'. Default để trongwithDefaults. 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.emitschư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[]).- React gộp emit vào callback
onChange. Vue tách props và emit, nên type củav-modellà chuyện riêng. ParseUserIdbranded ở route phía parent, khôngastrong 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ần | Vue | Tương đương React |
|---|---|---|
| Props | defineProps<T>() | Một type object props |
| Emits | defineEmits<{ ev: [T] }>() | onChange: (v: T) => void |
v-model | modelValue + emit update / defineModel | Value controlled + onChange |
| Attrs / ref | inheritAttrs: false + $attrs có type | ComponentProps<'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.rolevẫ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ánanycho option. - Hydration: default props dùng
Date.now()hoặcMath.random()trong factory sẽ lệch SSR. vue-tsctrên parent phải fail khiuserIdsai 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ý:defineEmitscó type.- Triệu chứng: mọi parent đều
asuserId. Nguyên nhân: prop typestring. Cách xử lý:UserIdbranded, 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-tsctrên parent phải fail khiuserIdsai hoặc thiếu listener.v-modellàmodelValuekèm emit update.defineModellà sugar.
Câu hỏi nối tiếp thường gặp
- Props runtime khác type-only? Runtime
String/Booleancho custom element và chỗ chỉ dùng JS. App chỉ TS dùngdefineProps<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ặcMath.random()trongwithDefaultslàm lệch hydration.