Phỏng Vấn Frontend Senior: TypeScript Sâu — Generics, Conditional Types và Type System Thực Chiến
Bài này có bản tiếng Anh. Read in English →
Nguyen Hai Trinh • Senior Software Engineer @ SmileViet
Ở vòng Senior, câu hỏi TypeScript hiếm khi là “interface khác type ở đâu”. Mình ngồi cả hai ghế rồi, và thứ họ đưa ra thường là một API client, một form lớn, hay một thư viện nội bộ đang đầy any, kèm đúng một câu: “em sẽ làm cho nó type-safe thế nào, và type đó có đáng để duy trì không”. Họ muốn thấy bạn dùng type system như một công cụ thiết kế. Bắt lỗi sớm. Autocomplete có ích. Nhưng không biến codebase thành mê cung mà đồng đội không ai sửa nổi.
Mỗi mục giữ nhịp phỏng vấn: câu hỏi, câu trả lời kiểu “dùng được nhưng chưa sâu”, câu trả lời ở mức senior, rồi câu follow-up bẫy. Code type trong bài đã chạy qua tsc --strict, kể cả các dòng @ts-expect-error — lỗi đó thật sự xuất hiện.
1. Generics có constraint: viết hàm “đúng kiểu” thay vì “đại khái”
Interviewer hỏi: “Viết hàm pick(obj, keys) trả về object chỉ gồm các key được chọn, sao cho gõ sai key thì báo lỗi và kiểu trả về chính xác.”
Trả lời kiểu “chạy được nhưng chưa sâu”: function pick(obj: any, keys: string[]): any. Chạy thì chạy, nhưng bay sạch thông tin kiểu. Gọi xong không có autocomplete. Gõ sai key, không ai báo.
Câu trả lời senior: hai tham số kiểu, tham số thứ hai bị tham số thứ nhất ràng buộc.
function pick<T extends object, K extends keyof T>(
obj: T,
keys: readonly K[],
): Pick<T, K> {
const out = {} as Pick<T, K>;
for (const k of keys) out[k] = obj[k];
return out;
}
const user = { id: 1, name: "An", role: "admin" as const };
const small = pick(user, ["id", "name"]); // { id: number; name: string }
// @ts-expect-error: "email" không phải key của user
pick(user, ["email"]);
K extends keyof T là chỗ ăn tiền. TypeScript suy K từ mảng literal ("id" | "name"), và phần tử nào không thuộc keyof T thì báo lỗi ngay tại lời gọi. Constraint còn mở cửa cho mấy thao tác bên trong hàm: obj[k] hợp lệ chỉ vì k thuộc keyof T.
Thực tế hơn thì có groupBy, kiểu của key quyết định luôn kiểu của kết quả:
function groupBy<T, K extends PropertyKey>(
items: readonly T[],
key: (item: T) => K,
): Record<K, T[]> {
const out = {} as Record<K, T[]>;
for (const item of items) {
const k = key(item);
(out[k] ??= []).push(item);
}
return out;
}
const grouped = groupBy(
[{ n: 1, t: "a" as const }, { n: 2, t: "b" as const }],
(x) => x.t,
);
// grouped: Record<"a" | "b", { n: number; t: "a" | "b" }[]>
Còn kiểu constraint cấu trúc, hay dùng cho helper trong app:
interface HasId {
id: string;
}
function indexById<T extends HasId>(items: readonly T[]): Map<T["id"], T> {
return new Map(items.map((i) => [i.id, i] as const));
}
Follow-up bẫy: “Khi nào generics là thừa?” Quy tắc mình dùng: một tham số kiểu phải xuất hiện ít nhất hai lần — đầu vào với đầu ra, hoặc hai đầu vào liên quan nhau. function log<T>(x: T): void dùng T đúng một lần, chẳng mang thêm thông tin gì so với x: unknown. Generics thừa làm chữ ký khó đọc, thông báo lỗi dài hơn, mà chả được gì.
2. Conditional types, infer và cái bẫy phân phối trên union
Interviewer hỏi: “Conditional type là gì? infer dùng để làm gì? Cho ví dụ mà em đã tự viết.”
Trả lời kiểu “chạy được nhưng chưa sâu”: “Là T extends U ? X : Y, ternary cho kiểu.” Đúng cú pháp. Hành vi thì chưa nói được gì.
Câu trả lời senior: conditional type chọn kiểu dựa trên quan hệ gán được (assignability). infer khai báo biến kiểu ngay trong mệnh đề extends, để bắt lấy phần kiểu khớp với mẫu:
type UnwrapPromise<T> = T extends Promise<infer U> ? UnwrapPromise<U> : T;
type Deep = UnwrapPromise<Promise<Promise<number>>>; // number
type ElementOf<T> = T extends readonly (infer U)[] ? U : never;
type Tag = ElementOf<readonly ["a", "b"]>; // "a" | "b"
type ReturnTypeOf<F> = F extends (...args: any[]) => infer R ? R : never;
type R1 = ReturnTypeOf<() => number>; // number
any[] trong ReturnTypeOf là một trong số ít chỗ any hợp lý. Ta cần “mọi danh sách tham số”, mà nó lại nằm ở vị trí tham số của hàm (contravariant), nên unknown[] sẽ làm nhiều hàm không khớp. Thư viện chuẩn viết ReturnType y hệt.
Chỗ bị hỏi sâu là distributive conditional type. Khi T là tham số kiểu “trần” (naked) và được truyền vào một union, conditional type áp cho từng thành viên của union rồi gộp lại:
type ToArray<T> = T extends unknown ? T[] : never;
type A = ToArray<string | number>; // string[] | number[]
// Bọc trong tuple để tắt phân phối
type ToArrayAll<T> = [T] extends [unknown] ? T[] : never;
type B = ToArrayAll<string | number>; // (string | number)[]
Kéo theo một hệ quả: never là union rỗng, nên ToArray<never> ra never chứ không phải never[]. Bug type khó hiểu sinh ra từ đây khá nhiều. Muốn so sánh nguyên cả union thì bọc [T].
Follow-up bẫy: “Type đệ quy như UnwrapPromise có giới hạn không?” Có. Compiler chặn độ sâu khi khởi tạo type — báo lỗi “Type instantiation is excessively deep and possibly infinite” — và type quá phức tạp làm editor ì đi. Type ở mức thư viện nội bộ thì sống được, miễn là có test (dùng @ts-expect-error hoặc công cụ kiểm tra type) và có người hiểu nó. Logic nghiệp vụ thì nên để type đơn giản, phần còn lại giao cho runtime validation.
3. Mapped types và template literal types: đổi tên key bằng type
Interviewer hỏi: “Làm sao biến { id: number; name: string } thành { getId: () => number; getName: () => string } bằng type?”
Trả lời kiểu “chạy được nhưng chưa sâu”: “Viết tay từng cái”, hoặc “dùng Partial, Pick”. Utility types thì biết dùng, nhưng chưa tự viết được một mapped type.
Câu trả lời senior: mapped type duyệt qua các key của một kiểu, còn mệnh đề as cho phép đổi tên key (key remapping). Ghép với template literal type và mấy intrinsic như Capitalize:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type User = { id: number; name: string };
type UserGetters = Getters<User>;
// { getId: () => number; getName: () => string }
string & K là bắt buộc: keyof T có thể gồm cả number với symbol, mà Capitalize chỉ nhận string. Modifier cũng thuộc về mapped type, -readonly và -? gỡ thuộc tính tương ứng:
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Nullable<T> = { [K in keyof T]: T[K] | null };
Template literal type mạnh nhất khi đi cùng infer. Ví dụ trích tên tham số từ route /users/:id/posts/:postId:
type ParamNames<P extends string> =
P extends `${string}:${infer Name}/${infer Rest}`
? Name | ParamNames<`/${Rest}`>
: P extends `${string}:${infer Name}`
? Name
: never;
type RouteParams<P extends string> = { [K in ParamNames<P>]: string };
const ok: RouteParams<"/users/:id/posts/:postId"> = { id: "1", postId: "2" };
// @ts-expect-error: thiếu "id"
const bad: RouteParams<"/users/:id"> = {};
Đọc type này thì đọc ngược từ dưới lên. Chuỗi có :tên rồi tới / thì lấy tên đó, đệ quy phần còn lại. :tên nằm cuối chuỗi thì lấy luôn. Template literal còn cho kiểu như `${T}Changed` phân phối trên union — "user" | "cart" thành "userChanged" | "cartChanged".
Follow-up bẫy: “Em có dùng kiểu này để type-check toàn bộ URL không?” Cẩn thận. Template literal bắt lỗi chính tả ở tên route với tham số, nhưng không thay được validate lúc chạy, vì dữ liệu từ server vào là unknown. Mình dùng nó ở phía gọi (call-site), không dùng ở phía nhận dữ liệu.
4. Narrowing và discriminated union: mỗi trạng thái chỉ mang dữ liệu của nó
Interviewer hỏi: “Mô hình hóa trạng thái của một request: idle, loading, success, error, sao cho không thể truy cập data khi chưa thành công.”
Trả lời kiểu “chạy được nhưng chưa sâu”: một interface với loading: boolean; data?: T; error?: Error. Kiểu này cho phép trạng thái bất khả thi — loading: true mà vẫn kèm error. data thì lúc nào cũng có thể undefined, nên code đầy dấu ? với !.
Câu trả lời senior: discriminated union, với một trường literal làm thẻ phân biệt. Mỗi trạng thái chỉ chứa dữ liệu hợp lệ của nó:
type FetchState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function assertNever(x: never): never {
throw new Error(`Unhandled: ${JSON.stringify(x)}`);
}
function render(s: FetchState<string[]>): string {
switch (s.status) {
case "idle":
case "loading":
return "...";
case "success":
return s.data.join(", "); // s đã thu hẹp thành nhánh success
case "error":
return s.error.message;
default:
return assertNever(s); // thêm trạng thái mới mà quên xử lý -> lỗi compile
}
}
assertNever cho phép kiểm tra exhaustive. Ai đó thêm { status: "stale" } vào union mà quên cập nhật switch thì s ở nhánh default không còn là never, compiler báo lỗi ngay. Test khó mà bắt được kiểu lỗi này.
Bốn nhánh của union, và mỗi nhánh cho bạn đúng thứ nó mang:
flowchart TD U["FetchState: union 4 trạng thái"] --> I["idle — không mang dữ liệu"] U --> L["loading — không mang dữ liệu"] U --> S["success — mang data: T"] U --> E["error — mang error: Error"] S --> SP["switch vào nhánh success: s.data dùng được"] E --> EP["switch vào nhánh error: s.error.message"] U --> EX["thêm status mới mà quên xử lý"] --> DF["default rơi vào assertNever(s): lỗi compile"]
Ngoài switch, các cách narrowing khác mà interviewer thường hỏi:
// Type predicate: người dùng tự định nghĩa guard
function isDefined<T>(v: T | null | undefined): v is T {
return v != null;
}
const defined: number[] = [1, null, 2].filter(isDefined);
// Assertion function: ném lỗi nếu sai, thu hẹp phần code phía sau
function assertString(v: unknown): asserts v is string {
if (typeof v !== "string") throw new TypeError("not a string");
}
// Guard cho dữ liệu chưa biết kiểu
function isUser(x: unknown): x is { id: number; name: string } {
return (
typeof x === "object" &&
x !== null &&
"id" in x &&
typeof x.id === "number" &&
"name" in x &&
typeof x.name === "string"
);
}
Follow-up bẫy: “Type predicate có an toàn không?” Không hoàn toàn: compiler tin bạn. Nếu isUser viết sai (quên kiểm tra name), nó vẫn trả x is ... và type sai lan ra khắp nơi. Vì vậy với dữ liệu ngoài biên hệ thống (API, localStorage, postMessage), mình ưu tiên thư viện schema (Zod, Valibot hoặc tương đương) để một khai báo vừa validate lúc chạy vừa sinh type, thay vì tự viết guard bằng tay cho từng kiểu.
5. satisfies: kiểm tra mà không “làm phẳng” kiểu
Interviewer hỏi: “Khác nhau giữa const x: T = ..., ... as T và ... satisfies T?”
Trả lời kiểu “chạy được nhưng chưa sâu”: “satisfies là cách mới để type-check.” Lợi ích nằm ở đâu thì chịu.
Câu trả lời senior: ba cách, ba nhu cầu khác nhau.
- Annotation
: Tthay kiểu suy luận bằngT; phần thông tin cụ thể bị mất. - Assertion
as Ttắt kiểm tra (chỉ cần hai kiểu đủ gần nhau), nên dễ che giấu lỗi. satisfies Tkiểm tra giá trị khớpTnhưng giữ nguyên kiểu suy luận hẹp nhất.
const routes = {
home: "/",
user: "/users/:id",
} satisfies Record<string, string>;
type RouteName = keyof typeof routes; // "home" | "user" (không phải string)
const palette = {
red: [255, 0, 0],
green: "#00ff00",
} satisfies Record<string, string | number[]>;
palette.red.map((n) => n * 2); // OK: red vẫn là number[]
palette.green.toUpperCase(); // OK: green vẫn là string
const config = { env: "prod", retries: 3 } as const satisfies {
env: "dev" | "prod";
retries: number;
};
// config.env có kiểu "prod", không phải string
Đổi sang annotation Record<string, string | number[]> ở ví dụ palette, palette.red.map báo lỗi ngay, vì red bị nới thành string | number[]. Dùng as thì một typo trong cấu hình lọt qua êm ru. satisfies chặn cả hai đầu. Tổ hợp as const satisfies là mẫu mình dùng nhiều nhất cho bảng cấu hình, route map với registry.
Follow-up bẫy: “Có chỗ nào satisfies không giúp gì?” Có. Nó không đổi kiểu của biến, nên khi cần một kiểu khai báo làm hợp đồng công khai — kiểu trả về của hàm export chẳng hạn — annotation vẫn đúng hơn. satisfies cũng không kiểm tra dữ liệu runtime.
6. Type-safe API client: một bảng schema, suy ra mọi thứ
Câu hỏi tổng hợp, gặp nhiều ở vòng senior: “Thiết kế hàm request sao cho tên endpoint, tham số đường dẫn, body và kiểu trả về đều được kiểm tra”.
Trả lời kiểu “chạy được nhưng chưa sâu”: request<T>(url: string, body?: unknown): Promise<T>. Người gọi tự khai T. Tự hứa với compiler, chứ chẳng ai ngăn họ khai sai.
Câu trả lời senior: gom hợp đồng API vào một bảng schema, rồi suy mọi thứ ra từ đó.
interface User { id: number; name: string }
interface CreateUser { name: string }
interface ApiSchema {
"GET /users": { response: User[] };
"GET /users/:id": { response: User };
"POST /users": { body: CreateUser; response: User };
"PATCH /users/:id": { body: Partial<CreateUser>; response: User };
}
type PathOf<K extends string> = K extends `${string} ${infer P}` ? P : never;
type ParamsOpt<K extends string> = [ParamNames<PathOf<K>>] extends [never]
? unknown
: { params: Record<ParamNames<PathOf<K>>, string> };
type BodyOpt<K extends keyof ApiSchema> = ApiSchema[K] extends {
body: infer B;
}
? { body: B }
: unknown;
type RequestOptions<K extends keyof ApiSchema> = ParamsOpt<K & string> &
BodyOpt<K>;
// Route không cần tham số/body thì không phải truyền đối số thứ hai
type RequestArgs<K extends keyof ApiSchema> =
keyof RequestOptions<K> extends never ? [] : [options: RequestOptions<K>];
async function request<K extends keyof ApiSchema & string>(
route: K,
...args: RequestArgs<K>
): Promise<ApiSchema[K]["response"]> {
const [method, template] = route.split(" ") as [string, string];
const options = (
args as unknown as [{ params?: Record<string, string>; body?: unknown }?]
)[0];
const path = template.replace(/:(\w+)/g, (_, name: string) =>
encodeURIComponent(options?.params?.[name] ?? ""),
);
const res = await fetch(path, {
method,
headers: { "Content-Type": "application/json" },
body: options?.body === undefined ? undefined : JSON.stringify(options.body),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as ApiSchema[K]["response"];
}
(ParamNames là type ở mục 3.) Phía người dùng thì như thế này:
async function demo() {
const list = await request("GET /users"); // User[]
const one = await request("GET /users/:id", { params: { id: "1" } }); // User
const made = await request("POST /users", { body: { name: "An" } }); // User
// @ts-expect-error: thiếu params.id
await request("GET /users/:id");
// @ts-expect-error: body sai tên trường
await request("POST /users", { body: { nme: "x" } });
// @ts-expect-error: route không tồn tại trong schema
await request("DELETE /users");
return [list, one, made];
}
Các type ở trên nối với nhau thành một chuỗi, đi từ tên route tới kiểu trả về:
flowchart TD K["keyof ApiSchema: bốn route trong bảng"] --> P["PathOf: tách phần path sau dấu cách"] P --> PN["ParamNames: ':id' thành 'id'"] PN --> PO["ParamsOpt: route có param thì bắt buộc truyền params"] K --> BO["BodyOpt: schema có nhánh body thì bắt buộc truyền body"] PO --> RO["RequestOptions: ghép ParamsOpt với BodyOpt"] BO --> RO RO --> RA["RequestArgs: không có option nào thì gọi không cần đối số thứ hai"] RA --> RESP["Kiểu trả về: ApiSchema[K] nhánh response"]
Phần “bẩn” của lời giải thì phải nói thẳng. Bên trong request mình ép kiểu hai lần (as unknown as, as ApiSchema[K]["response"]). Compiler không chứng minh được thân hàm đúng với kiểu điều kiện nằm ở chữ ký. Đổi lại: ép kiểu bị nhốt trong một chỗ, còn bề mặt API công khai thì chặt. Còn as ... ["response"] ở cuối chỉ là lời hứa về server, không phải điều TypeScript kiểm chứng. Server trả khác schema là lỗi nổ ở runtime. Muốn chắc thì sinh schema từ OpenAPI, hoặc validate response bằng thư viện schema ngay ở biên.
Follow-up bẫy: “Vì sao không dùng overload cho từng endpoint?” Vài endpoint thì overload ổn, nhưng phải viết tay và rất dễ lệch khỏi thực tế. Bảng schema là nguồn sự thật duy nhất, thêm endpoint là thêm một dòng. Đổi lại, kiểu suy luận phức tạp có giá của nó: thông báo lỗi khó đọc hơn. Một lớp mỏng các hàm có tên (getUser(id)) bọc lên trên request là đủ để lỗi dễ hiểu trở lại.
7. Type-safe form: đường dẫn lồng nhau
Interviewer hỏi: “Form có dữ liệu lồng nhau (profile.address.city). Làm sao getValue(form, 'profile.address.city') có kiểu trả về đúng và gõ sai đường dẫn thì báo lỗi?”
Câu trả lời senior: cần hai type. Một sinh ra tập hợp mọi đường dẫn hợp lệ. Một tra kiểu giá trị theo đường dẫn.
type Path<T> = T extends object
? {
[K in keyof T & string]: T[K] extends object
? K | `${K}.${Path<T[K]>}`
: K;
}[keyof T & string]
: never;
type PathValue<T, P extends string> = P extends `${infer Head}.${infer Rest}`
? Head extends keyof T
? PathValue<T[Head], Rest>
: never
: P extends keyof T
? T[P]
: never;
function getValue<T, P extends Path<T>>(obj: T, path: P): PathValue<T, P> {
let cur: unknown = obj;
for (const key of path.split(".")) {
cur = (cur as Record<string, unknown>)[key];
}
return cur as PathValue<T, P>;
}
interface SignupForm {
email: string;
profile: { age: number; address: { city: string } };
}
declare const form: SignupForm;
const city: string = getValue(form, "profile.address.city");
const age: number = getValue(form, "profile.age");
// @ts-expect-error: "agee" không tồn tại
getValue(form, "profile.agee");
Path<SignupForm> là union "email" | "profile" | "profile.age" | "profile.address" | "profile.address.city", nên gõ tới đâu editor gợi ý tới đó. Thư viện form hiện đại type mấy hàm như register("profile.age") hay watch cũng bằng cơ chế này.
Giới hạn của bản trên: không xử lý mảng (items.0.name), và chưa tính tới Date hay mấy object đặc biệt — chúng cũng extends object, nên bị “mở” ra theo từng thuộc tính. Thư viện thật có xử lý. Bạn không phát triển thư viện thì cứ dùng lại type của thư viện form, đừng tự viết.
Follow-up bẫy: “Type này có ảnh hưởng hiệu năng compile không?” Có. Path đệ quy trên object lớn sinh ra union rất nhiều thành viên, editor chậm thấy rõ. Cách xử lý: đo bằng tsc --extendedDiagnostics, giới hạn độ sâu đệ quy, hoặc chấp nhận kiểu lỏng hơn (string) cho form khổng lồ. Type-safety có ngân sách, và senior biết lúc nào nên dừng.
8. Tránh any mà không đánh đổi sự thực dụng
Interviewer hỏi: “Codebase đang có hàng trăm any. Em xử lý thế nào?”
Trả lời kiểu “chạy được nhưng chưa sâu”: “Thay any bằng unknown”, hoặc “bật strict là xong”. Đúng hướng. Nhưng thiếu chiến lược.
Câu trả lời senior: chia nhóm trước đã. Mỗi loại any cần một cách xử lý khác nhau:
anytừ biên hệ thống:JSON.parse,fetch().json(),localStorage. Đây là nguồn gốc của hầu hết type sai. Xử lý: hàm trảunknown, rồi validate (schema hoặc guard) trước khi thu hẹp.anydo lười: props không khai báo, callback(e: any). Xử lý: khai báo đúng kiểu; dùng quy tắc lintno-explicit-anyở mức cảnh báo rồi nâng dần thành lỗi theo từng thư mục.anycó lý do: biên giới với thư viện thiếu type, vị trí tham số contravariant như ở mục 2. Xử lý: cô lập trong một file wrapper, thêm comment lý do, và type hóa phía công khai.
Ba nhóm đó, gom lại thành một hình:
flowchart TD A["any trong codebase"] --> B["any ở biên hệ thống"] A --> C["any do lười"] A --> D["any có lý do"] B --> B1["JSON.parse, fetch().json(), localStorage"] B1 --> B2["hàm trả unknown rồi validate bằng schema hoặc guard"] C --> C1["props không khai báo, callback (e: any)"] C1 --> C2["khai báo đúng kiểu, siết lint no-explicit-any theo thư mục"] D --> D1["thư viện thiếu type, tham số contravariant"] D1 --> D2["cô lập trong một file wrapper, thêm comment lý do, type hoá phía công khai"]
function parseJson(text: string): unknown {
return JSON.parse(text);
}
const data = parseJson("{}");
// data.name // lỗi: 'data' is of type 'unknown'
if (isUser(data)) {
data.name.toUpperCase(); // an toàn sau khi thu hẹp
}
// Object.keys trả string[] vì object có thể có thêm key lúc chạy.
// Ép kiểu có chủ đích, khi bạn biết object không bị mở rộng:
const keys = Object.keys(form) as (keyof SignupForm)[];
// Branded type: tránh nhầm lẫn giữa các id đều là string
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
declare function loadUser(id: UserId): void;
// @ts-expect-error: string thường không phải UserId
loadUser("abc");
loadUser("abc" as UserId); // ép kiểu tập trung ở chỗ tạo id (ví dụ hàm parse)
Hai cờ cấu hình mình luôn hỏi lại trong team: strict: true (gồm strictNullChecks, noImplicitAny…) và noUncheckedIndexedAccess, cờ buộc arr[i] với record[key] có kiểu T | undefined. Bật cờ sau tốn công sửa lúc đầu, đổi lại chặn được cả một lớp lỗi “đọc phần tử không tồn tại”.
Follow-up bẫy: “unknown và any khác nhau ở đâu?” Cả hai nhận mọi giá trị. Nhưng any tắt kiểm tra — gọi gì cũng được. unknown thì buộc bạn thu hẹp trước khi dùng. Ý cộng điểm: any lây. Một giá trị any đi qua một hàm là kiểu trả về cũng thành any, nên một chỗ lỏng vô hiệu hoá kiểm tra của cả chuỗi phía sau.
Interviewer nhìn gì ở một bạn Senior
Ngồi cả hai ghế rồi, mình để ý mấy tín hiệu này:
- Biết type để làm gì. Ứng viên mạnh bắt đầu từ vấn đề (“bug nào mình muốn chặn?”) rồi mới chọn công cụ type. Ứng viên yếu khoe type phức tạp mà không giải thích nó chặn lỗi gì.
- Biết giới hạn. Type chỉ tồn tại lúc compile. Ai khẳng định “đã type-safe hoàn toàn” khi dữ liệu đến từ mạng là chưa hiểu biên giới giữa compile-time và runtime.
- Biết chi phí. Type đệ quy, union khổng lồ, thông báo lỗi dài nửa màn hình đều là nợ bảo trì. Senior cân nhắc: đồng đội sửa được không, và có test cho type không.
- Cô lập sự bẩn. Ép kiểu không phải tội lỗi nếu nó nằm ở một chỗ có chủ đích, có comment, với bề mặt công khai chặt chẽ.
- Thể hiện quy trình. Mô tả cách bạn đưa type-safety vào codebase cũ theo từng bước: bật
stricttheo thư mục, siết lint dần, ưu tiên biên hệ thống trước.
Checklist ôn tập
- Tự viết lại từ đầu, không nhìn:
Pick,Omit,ReturnType,Awaited,Parameters, để hiểu chúng được dựng từ mapped type vàinfer. - Làm một hàm generics có hai tham số kiểu liên hệ nhau (như
pick,groupBy) và giải thích từng constraint. - Mô hình hóa một luồng nghiệp vụ bằng discriminated union và
assertNever, rồi thử thêm một trạng thái mới để thấy compiler chặn. - Viết một
requesttype-safe nhỏ cho API của chính bạn, ghi lại chỗ nào phải ép kiểu và vì sao. - Chuẩn bị một câu chuyện thật: type nào từng cứu bạn khỏi bug, và type nào bạn đã phải đơn giản hóa vì nó quá khó bảo trì. Câu chuyện thứ hai thường gây ấn tượng hơn.
Trong phòng phỏng vấn, conditional type dài nhất không phải là điểm cộng. Ba câu mình hay tự hỏi khi review type của chính mình: nó chặn được lỗi gì, đồng đội có đọc được không, và chỗ nào compiler không bảo đảm.
Đọc tiếp
Cần người cho dự án contract hoặc freelance?
Mình nhận dự án fullstack — backend Go, frontend React/Next.js, hoặc cả hai đầu. Gửi vài dòng về phạm vi công việc và thời gian, mình phản hồi trong 48 giờ.