TypeScript Advanced Types: Phép Thuật Conditional, Mapped và Template Literal
Khám phá sâu các tính năng type mạnh mẽ nhất của TypeScript — conditional types, mapped types, từ khóa infer và template literal types. Các pattern thực tế mà bạn sẽ thực sự sử dụng.
Hầu hết các lập trình viên TypeScript đều bị mắc kẹt ở cùng một chỗ. Họ biết interfaces, generics, union types. Họ có thể type một React component. Họ có thể làm cho các gạch đỏ biến mất. Nhưng họ đối xử với hệ thống type như một thứ bắt buộc phải chịu đựng — một thứ tồn tại để ngăn bugs, chứ không phải thứ chủ động giúp họ thiết kế phần mềm tốt hơn.
Tôi đã ở mức đó khoảng hai năm. Thứ kéo tôi ra là nhận ra rằng hệ thống type của TypeScript tự nó là một ngôn ngữ lập trình. Nó có conditionals, loops, pattern matching, đệ quy và thao tác chuỗi. Khi bạn nắm được điều đó, mọi thứ thay đổi. Bạn ngừng chiến đấu với compiler và bắt đầu cộng tác với nó.
Bài viết này bao gồm các tính năng type-level mà tôi sử dụng liên tục trong code production. Không phải bài tập lý thuyết — mà là các pattern thực tế đã cứu tôi khỏi các bugs thực sự.
Tư Duy Lập Trình Type-Level#
Trước khi đi vào cú pháp, tôi muốn thay đổi cách bạn nghĩ về types.
Trong lập trình value-level, bạn viết các hàm biến đổi dữ liệu tại runtime:
function extractIds(users: User[]): number[] {
return users.map(u => u.id);
}Trong lập trình type-level, bạn viết các types biến đổi các types khác tại compile time:
type ExtractIds<T extends { id: unknown }[]> = T[number]["id"];Mô hình tư duy là giống nhau — đầu vào, biến đổi, đầu ra. Sự khác biệt là code type-level chạy trong quá trình biên dịch, không phải tại runtime. Nó tạo ra zero JavaScript. Mục đích duy nhất của nó là làm cho các trạng thái bất khả thi không thể biểu diễn được.
Nếu bạn đã từng viết một hàm mà ba trong bốn tham số chỉ có ý nghĩa trong một số tổ hợp nhất định, hoặc kiểu trả về phụ thuộc vào thứ bạn truyền vào, bạn đã cần lập trình type-level rồi. Bạn chỉ có thể chưa biết TypeScript có thể biểu diễn được điều đó.
Conditional Types#
Conditional types là if/else của hệ thống type. Cú pháp trông giống ternary:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
type C = IsString<string>; // trueTừ khóa extends ở đây không có nghĩa là kế thừa. Nó có nghĩa là "có thể gán được cho." Hãy nghĩ nó như "T có khớp với hình dạng của string không?"
Xây Dựng Utility Types Của Riêng Bạn#
Hãy xây dựng lại một số utility types tích hợp sẵn của TypeScript để hiểu cách chúng hoạt động.
NonNullable — loại bỏ null và undefined khỏi một union:
type MyNonNullable<T> = T extends null | undefined ? never : T;
type Test = MyNonNullable<string | null | undefined>;
// Result: stringType never là "tập rỗng" — loại bỏ một thứ gì đó khỏi union. Khi một nhánh của conditional type resolve thành never, thành viên đó của union đơn giản biến mất.
Extract — chỉ giữ lại các thành viên union khớp với ràng buộc:
type MyExtract<T, U> = T extends U ? T : never;
type Numbers = Extract<string | number | boolean, number | boolean>;
// Result: number | booleanExclude — ngược lại, loại bỏ các thành viên khớp:
type MyExclude<T, U> = T extends U ? never : T;
type WithoutStrings = Exclude<string | number | boolean, string>;
// Result: number | booleanDistributive Conditional Types#
Đây là nơi mọi thứ trở nên thú vị, và cũng là nơi hầu hết mọi người bị nhầm lẫn. Khi bạn truyền một union type vào conditional type, TypeScript phân phối điều kiện trên từng thành viên của union riêng lẻ.
type ToArray<T> = T extends unknown ? T[] : never;
type Result = ToArray<string | number>;
// Bạn có thể nghĩ: (string | number)[]
// Kết quả thực tế: string[] | number[]TypeScript không đánh giá (string | number) extends unknown. Thay vào đó, nó đánh giá string extends unknown và number extends unknown riêng biệt, rồi union các kết quả.
Đây là lý do tại sao Extract và Exclude hoạt động theo cách đó. Sự phân phối xảy ra tự động khi T là một type parameter "trần" (không được bọc trong bất cứ thứ gì).
Nếu bạn muốn ngăn chặn sự phân phối, bọc cả hai bên trong một tuple:
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
type Result = ToArrayNonDist<string | number>;
// Result: (string | number)[]Tôi đã bị cắn bởi điều này nhiều lần hơn tôi muốn thừa nhận. Nếu conditional type của bạn cho kết quả không mong đợi với đầu vào union, sự phân phối gần như luôn là lý do.
Ví Dụ Thực Tế: API Response Handler#
Đây là pattern tôi sử dụng khi xử lý API responses có thể thành công hoặc thất bại:
type ApiResponse<T> =
| { status: "success"; data: T; error: never }
| { status: "error"; data: never; error: string };
type ExtractData<R> = R extends { status: "success"; data: infer D } ? D : never;
type ExtractError<R> = R extends { status: "error"; error: infer E } ? E : never;
type UserResponse = ApiResponse<{ id: number; name: string }>;
type UserData = ExtractData<UserResponse>;
// Result: { id: number; name: string }
type UserError = ExtractError<UserResponse>;
// Result: stringCách này sạch hơn so với viết type guards thủ công cho mỗi hình dạng response.
Từ Khóa infer#
infer là pattern matching cho types. Nó cho phép bạn khai báo một biến type bên trong conditional type mà TypeScript sẽ tự tìm ra cho bạn.
Hãy nghĩ nó như: "Tôi biết type này có một hình dạng nhất định. Hãy trích xuất phần tôi quan tâm."
Trích Xuất Return Types#
type MyReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
type Fn = (x: number, y: string) => boolean;
type Result = MyReturnType<Fn>; // booleanTypeScript nhìn thấy pattern (...args) => something, khớp hàm của bạn với nó, và gán kiểu trả về cho R.
Trích Xuất Parameter Types#
type MyParameters<T> = T extends (...args: infer P) => unknown ? P : never;
type Fn = (x: number, y: string) => void;
type Params = MyParameters<Fn>; // [x: number, y: string]Unwrapping Promises#
Cái này tôi sử dụng mọi lúc khi làm việc với các hàm async:
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;
type A = UnpackPromise<Promise<string>>; // string
type B = UnpackPromise<Promise<number[]>>; // number[]
type C = UnpackPromise<string>; // string (passthrough)Cho promises lồng sâu:
type DeepUnpackPromise<T> = T extends Promise<infer U>
? DeepUnpackPromise<U>
: T;
type Deep = DeepUnpackPromise<Promise<Promise<Promise<number>>>>;
// Result: numberTrích Xuất Kiểu Phần Tử Mảng#
type ElementOf<T> = T extends (infer E)[] ? E : never;
type Items = ElementOf<string[]>; // string
type Mixed = ElementOf<(string | number)[]>; // string | numberNhiều infer Trong Một Type#
Bạn có thể sử dụng infer nhiều lần để trích xuất các phần khác nhau:
type FirstAndLast<T extends unknown[]> =
T extends [infer First, ...unknown[], infer Last]
? { first: First; last: Last }
: never;
type Result = FirstAndLast<[1, 2, 3, 4]>;
// Result: { first: 1; last: 4 }Thực Tế: Type-Safe Event Extraction#
Đây là pattern tôi sử dụng trong các hệ thống event-driven:
type EventMap = {
"user:login": { userId: string; timestamp: number };
"user:logout": { userId: string };
"order:created": { orderId: string; total: number };
"order:shipped": { orderId: string; trackingId: string };
};
type EventName = keyof EventMap;
type EventPayload<E extends EventName> = EventMap[E];
// Trích xuất tất cả events liên quan đến user
type UserEvents = Extract<EventName, `user:${string}`>;
// Result: "user:login" | "user:logout"
// Lấy payload chỉ cho user events
type UserEventPayloads = EventPayload<UserEvents>;
// Result: { userId: string; timestamp: number } | { userId: string }Mapped Types#
Mapped types cho phép bạn tạo các object types mới bằng cách biến đổi từng property của một type hiện có. Chúng là map() của hệ thống type.
Cơ Bản#
type Readonly<T> = {
readonly [K in keyof T]: T[K];
};
type Partial<T> = {
[K in keyof T]?: T[K];
};keyof T cho bạn union của tất cả các property keys. [K in ...] duyệt qua từng cái. T[K] là type của property đó.
Xây Dựng Utility Types Từ Đầu#
Required — làm tất cả properties trở thành bắt buộc:
type MyRequired<T> = {
[K in keyof T]-?: T[K];
};Cú pháp -? loại bỏ modifier optional. Tương tự, -readonly loại bỏ readonly.
Pick — chọn các properties cụ thể:
type MyPick<T, K extends keyof T> = {
[P in K]: T[P];
};
interface User {
id: number;
name: string;
email: string;
password: string;
}
type PublicUser = MyPick<User, "id" | "name" | "email">;
// { id: number; name: string; email: string }Omit — loại bỏ các properties cụ thể:
type MyOmit<T, K extends keyof T> = {
[P in Exclude<keyof T, K>]: T[P];
};
type SafeUser = MyOmit<User, "password">;
// { id: number; name: string; email: string }Record — tạo một object type với keys và value types cụ thể:
type MyRecord<K extends string | number | symbol, V> = {
[P in K]: V;
};
type StatusMap = MyRecord<"active" | "inactive" | "banned", boolean>;
// { active: boolean; inactive: boolean; banned: boolean }Key Remapping Với as#
TypeScript 4.1 giới thiệu key remapping, cho phép bạn biến đổi tên properties:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface Person {
name: string;
age: number;
email: string;
}
type PersonGetters = Getters<Person>;
// {
// getName: () => string;
// getAge: () => number;
// getEmail: () => string;
// }Bạn cũng có thể lọc properties bằng cách remap sang never:
type OnlyStrings<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};
type StringProps = OnlyStrings<Person>;
// { name: string; email: string }Điều này cực kỳ mạnh mẽ. Bạn đồng thời duyệt qua các properties, lọc chúng, và biến đổi tên của chúng — tất cả ở cấp type.
Ví Dụ Thực Tế: Form Validation Types#
Tôi đã sử dụng pattern này trong nhiều form libraries:
interface FormFields {
username: string;
email: string;
age: number;
bio: string;
}
type ValidationErrors<T> = {
[K in keyof T]?: string[];
};
type TouchedFields<T> = {
[K in keyof T]?: boolean;
};
type DirtyFields<T> = {
[K in keyof T]?: boolean;
};
type FormState<T> = {
values: T;
errors: ValidationErrors<T>;
touched: TouchedFields<T>;
dirty: DirtyFields<T>;
isValid: boolean;
isSubmitting: boolean;
};
// Giờ form state được type đầy đủ dựa trên các fields
type MyFormState = FormState<FormFields>;Thêm một field vào FormFields, và mọi type liên quan tự động cập nhật. Xóa một field, và compiler bắt mọi tham chiếu. Đây là loại thứ ngăn chặn toàn bộ các loại bugs.
Template Literal Types#
Template literal types cho phép bạn thao tác chuỗi ở cấp type. Chúng sử dụng cùng cú pháp backtick như JavaScript template literals, nhưng cho types.
Thao Tác Chuỗi Cơ Bản#
type Greeting = `Hello, ${string}`;
const a: Greeting = "Hello, world"; // OK
const b: Greeting = "Hello, TypeScript"; // OK
// const c: Greeting = "Hi, world"; // Error!TypeScript cũng cung cấp các string manipulation types tích hợp:
type Upper = Uppercase<"hello">; // "HELLO"
type Lower = Lowercase<"HELLO">; // "hello"
type Cap = Capitalize<"hello">; // "Hello"
type Uncap = Uncapitalize<"Hello">; // "hello"Event Name Patterns#
Đây là nơi template literal types thực sự tỏa sáng. Đây là pattern tôi sử dụng cho hệ thống event type-safe:
type EventNames<T extends string> = `${T}Changed` | `${T}Deleted` | `${T}Created`;
type ModelEvents = EventNames<"user" | "order" | "product">;
// "userChanged" | "userDeleted" | "userCreated"
// | "orderChanged" | "orderDeleted" | "orderCreated"
// | "productChanged" | "productDeleted" | "productCreated"Chín events từ hai type parameters. Và chúng đều type-safe.
CSS-in-TypeScript Patterns#
Template literal types có thể thực thi các CSS patterns ở cấp type:
type CSSUnit = "px" | "rem" | "em" | "vh" | "vw" | "%";
type CSSValue = `${number}${CSSUnit}`;
function setWidth(value: CSSValue): void {
// implementation
}
setWidth("100px"); // OK
setWidth("2.5rem"); // OK
setWidth("50%"); // OK
// setWidth("100"); // Error: not a valid CSS value
// setWidth("big"); // ErrorPhân Tích Chuỗi Với Template Literals#
Bạn có thể sử dụng template literal types với infer để phân tích chuỗi:
type ParseRoute<T extends string> =
T extends `${infer _Start}:${infer Param}/${infer Rest}`
? Param | ParseRoute<`/${Rest}`>
: T extends `${infer _Start}:${infer Param}`
? Param
: never;
type RouteParams = ParseRoute<"/users/:userId/posts/:postId">;
// Result: "userId" | "postId"Đây là nền tảng của cách các thư viện routing type-safe như tRPC hoạt động. Chuỗi /users/:userId/posts/:postId được phân tích ở cấp type để trích xuất tên tham số.
Kết Hợp Template Literals Với Mapped Types#
Đây là nơi mọi thứ thực sự mạnh mẽ:
type EventHandlers<T extends Record<string, unknown>> = {
[K in keyof T as `on${Capitalize<string & K>}Change`]: (
newValue: T[K],
oldValue: T[K]
) => void;
};
interface ThemeConfig {
color: string;
fontSize: number;
darkMode: boolean;
}
type ThemeHandlers = EventHandlers<ThemeConfig>;
// {
// onColorChange: (newValue: string, oldValue: string) => void;
// onFontSizeChange: (newValue: number, oldValue: number) => void;
// onDarkModeChange: (newValue: boolean, oldValue: boolean) => void;
// }Từ một config interface đơn giản, bạn có được các event handlers được type đầy đủ với đúng parameter types. Đây là loại code type-level thực sự tiết kiệm thời gian phát triển.
Nâng Cao: Dot-Notation Path Types#
Đây là một pattern nâng cao hơn — sinh ra tất cả các đường dẫn dot-notation khả thi qua một đối tượng lồng nhau:
type DotPaths<T, Prefix extends string = ""> = T extends object
? {
[K in keyof T & string]: T[K] extends object
? `${Prefix}${K}` | DotPaths<T[K], `${Prefix}${K}.`>
: `${Prefix}${K}`;
}[keyof T & string]
: never;
interface Config {
database: {
host: string;
port: number;
credentials: {
username: string;
password: string;
};
};
server: {
port: number;
};
}
type ConfigPaths = DotPaths<Config>;
// "database" | "database.host" | "database.port"
// | "database.credentials" | "database.credentials.username"
// | "database.credentials.password" | "server" | "server.port"Giờ bạn có thể viết một hàm getConfig với autocomplete mọi đường dẫn khả thi và trả về đúng type cho mỗi đường dẫn:
type GetValueByPath<T, P extends string> =
P extends `${infer Key}.${infer Rest}`
? Key extends keyof T
? GetValueByPath<T[Key], Rest>
: never
: P extends keyof T
? T[P]
: never;
function getConfig<P extends ConfigPaths>(path: P): GetValueByPath<Config, P> {
// implementation
const keys = path.split(".");
let result: unknown = config;
for (const key of keys) {
result = (result as Record<string, unknown>)[key];
}
return result as GetValueByPath<Config, P>;
}
const host = getConfig("database.host"); // type: string
const port = getConfig("database.port"); // type: number
const username = getConfig("database.credentials.username"); // type: stringAutocomplete đầy đủ, type safety đầy đủ, zero runtime overhead từ types.
Recursive Types#
TypeScript hỗ trợ định nghĩa type đệ quy, cho phép bạn xử lý các cấu trúc lồng nhau tùy ý.
Deep Partial#
Partial<T> tích hợp chỉ làm các properties cấp cao nhất thành optional. Cho các đối tượng lồng nhau, bạn cần đệ quy:
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object
? T[K] extends Array<infer U>
? Array<DeepPartial<U>>
: DeepPartial<T[K]>
: T[K];
};
interface Settings {
theme: {
colors: {
primary: string;
secondary: string;
};
fonts: {
heading: string;
body: string;
};
};
notifications: {
email: boolean;
push: boolean;
};
}
type PartialSettings = DeepPartial<Settings>;
// Mọi property lồng nhau giờ đều optionalLưu ý kiểm tra mảng: nếu không có nó, mảng sẽ bị coi như đối tượng và các chỉ số số của chúng sẽ trở thành optional, điều không mong muốn.
Deep Readonly#
Cùng pattern, khác modifier:
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object
? T[K] extends Function
? T[K]
: T[K] extends Array<infer U>
? ReadonlyArray<DeepReadonly<U>>
: DeepReadonly<T[K]>
: T[K];
};Kiểm tra Function rất quan trọng — nếu không có nó, các function properties sẽ bị áp readonly vào cấu trúc nội bộ, điều không có ý nghĩa.
JSON Type#
Đây là một type đệ quy kinh điển — biểu diễn bất kỳ giá trị JSON hợp lệ nào:
type JsonPrimitive = string | number | boolean | null;
type JsonArray = JsonValue[];
type JsonObject = { [key: string]: JsonValue };
type JsonValue = JsonPrimitive | JsonArray | JsonObject;
function parseJson(input: string): JsonValue {
return JSON.parse(input);
}
function isJsonObject(value: JsonValue): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}Điều này tốt hơn nhiều so với sử dụng any cho JSON parsing. Nó nói cho người sử dụng "bạn sẽ nhận lại một thứ gì đó, nhưng bạn cần thu hẹp nó trước khi sử dụng." Đó chính xác là sự thật.
Recursive Type Cho Menu Lồng Nhau#
Một ví dụ thực tế — typing cấu trúc menu điều hướng:
interface MenuItem {
label: string;
href: string;
icon?: string;
children?: MenuItem[];
}
type Menu = MenuItem[];
const navigation: Menu = [
{
label: "Products",
href: "/products",
children: [
{
label: "Software",
href: "/products/software",
children: [
{ label: "IDE", href: "/products/software/ide" },
{ label: "CLI Tools", href: "/products/software/cli" },
],
},
{ label: "Hardware", href: "/products/hardware" },
],
},
{ label: "About", href: "/about" },
];Property children đệ quy cho bạn khả năng lồng vô hạn với đầy đủ type safety.
Các Pattern Thực Tế#
Hãy để tôi chia sẻ một số pattern tôi đã thực sự sử dụng trong các hệ thống production.
Builder Pattern Với Types#
Builder pattern trở nên hữu ích hơn đáng kể khi hệ thống type theo dõi những gì đã được thiết lập:
type RequiredKeys = "host" | "port" | "database";
type BuilderState = {
[K in RequiredKeys]?: true;
};
class DatabaseConfigBuilder<State extends BuilderState = {}> {
private config: Partial<{
host: string;
port: number;
database: string;
ssl: boolean;
poolSize: number;
}> = {};
setHost(host: string): DatabaseConfigBuilder<State & { host: true }> {
this.config.host = host;
return this as unknown as DatabaseConfigBuilder<State & { host: true }>;
}
setPort(port: number): DatabaseConfigBuilder<State & { port: true }> {
this.config.port = port;
return this as unknown as DatabaseConfigBuilder<State & { port: true }>;
}
setDatabase(db: string): DatabaseConfigBuilder<State & { database: true }> {
this.config.database = db;
return this as unknown as DatabaseConfigBuilder<State & { database: true }>;
}
setSsl(ssl: boolean): DatabaseConfigBuilder<State> {
this.config.ssl = ssl;
return this as unknown as DatabaseConfigBuilder<State>;
}
setPoolSize(size: number): DatabaseConfigBuilder<State> {
this.config.poolSize = size;
return this as unknown as DatabaseConfigBuilder<State>;
}
build(
this: DatabaseConfigBuilder<{ host: true; port: true; database: true }>
): Required<Pick<typeof this.config, "host" | "port" | "database">> &
Partial<Pick<typeof this.config, "ssl" | "poolSize">> {
return this.config as ReturnType<typeof this.build>;
}
}
// Cái này compile:
const config = new DatabaseConfigBuilder()
.setHost("localhost")
.setPort(5432)
.setDatabase("myapp")
.setSsl(true)
.build();
// Cái này lỗi tại compile time — thiếu .setDatabase():
// const bad = new DatabaseConfigBuilder()
// .setHost("localhost")
// .setPort(5432)
// .build(); // Error: 'database' is missingPhương thức build() chỉ có thể được gọi khi tất cả các fields bắt buộc đã được thiết lập. Điều này được bắt tại compile time, không phải runtime.
Type-Safe Event Emitter#
Đây là event emitter nơi các payload types được thực thi:
type EventMap = Record<string, unknown>;
class TypedEventEmitter<Events extends EventMap> {
private listeners: {
[K in keyof Events]?: Array<(payload: Events[K]) => void>;
} = {};
on<K extends keyof Events>(
event: K,
listener: (payload: Events[K]) => void
): void {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event]!.push(listener);
}
emit<K extends keyof Events>(event: K, payload: Events[K]): void {
this.listeners[event]?.forEach((listener) => listener(payload));
}
off<K extends keyof Events>(
event: K,
listener: (payload: Events[K]) => void
): void {
const handlers = this.listeners[event];
if (handlers) {
this.listeners[event] = handlers.filter((h) => h !== listener) as typeof handlers;
}
}
}
// Sử dụng
interface AppEvents {
"user:login": { userId: string; timestamp: number };
"user:logout": { userId: string };
"notification:new": { message: string; level: "info" | "warning" | "error" };
}
const emitter = new TypedEventEmitter<AppEvents>();
// Được type đầy đủ — IDE autocomplete tên event và hình dạng payload
emitter.on("user:login", (payload) => {
console.log(payload.userId); // string
console.log(payload.timestamp); // number
});
// Type error: payload không khớp
// emitter.emit("user:login", { userId: "123" });
// Error: missing 'timestamp'
// Type error: event không tồn tại
// emitter.on("user:signup", () => {});
// Error: "user:signup" is not in AppEventsTôi đã sử dụng pattern này trong ba dự án production khác nhau. Nó bắt toàn bộ các loại bugs liên quan đến event tại compile time.
Discriminated Unions Với Exhaustive Checking#
Discriminated unions có lẽ là pattern hữu ích nhất trong TypeScript. Kết hợp với exhaustive checking, chúng đảm bảo bạn xử lý mọi trường hợp:
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rectangle"; width: number; height: number }
| { kind: "triangle"; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return 0.5 * shape.base * shape.height;
default:
// Dòng này đảm bảo exhaustive checking
const _exhaustive: never = shape;
return _exhaustive;
}
}Nếu ai đó thêm một shape variant mới (ví dụ "pentagon"), hàm này sẽ không compile vì never không thể được gán giá trị. Compiler buộc bạn xử lý mọi trường hợp.
Tôi đưa điều này xa hơn với một hàm helper:
function assertNever(value: never, message?: string): never {
throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`);
}
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return 0.5 * shape.base * shape.height;
default:
assertNever(shape, `Unknown shape kind: ${(shape as Shape).kind}`);
}
}Type-Safe State Machines#
Discriminated unions cũng mô hình hóa state machines rất đẹp:
type ConnectionState =
| { status: "disconnected" }
| { status: "connecting"; attempt: number }
| { status: "connected"; socket: WebSocket; connectedAt: Date }
| { status: "error"; error: Error; lastAttempt: Date };
type ConnectionAction =
| { type: "CONNECT" }
| { type: "CONNECTED"; socket: WebSocket }
| { type: "DISCONNECT" }
| { type: "ERROR"; error: Error }
| { type: "RETRY" };
function connectionReducer(
state: ConnectionState,
action: ConnectionAction
): ConnectionState {
switch (state.status) {
case "disconnected":
if (action.type === "CONNECT") {
return { status: "connecting", attempt: 1 };
}
return state;
case "connecting":
if (action.type === "CONNECTED") {
return {
status: "connected",
socket: action.socket,
connectedAt: new Date(),
};
}
if (action.type === "ERROR") {
return {
status: "error",
error: action.error,
lastAttempt: new Date(),
};
}
return state;
case "connected":
if (action.type === "DISCONNECT") {
state.socket.close();
return { status: "disconnected" };
}
if (action.type === "ERROR") {
return {
status: "error",
error: action.error,
lastAttempt: new Date(),
};
}
return state;
case "error":
if (action.type === "RETRY") {
return { status: "connecting", attempt: 1 };
}
if (action.type === "DISCONNECT") {
return { status: "disconnected" };
}
return state;
default:
assertNever(state, "Unknown connection state");
}
}Mỗi trạng thái chỉ có các properties có ý nghĩa cho trạng thái đó. Bạn không thể truy cập socket khi đã ngắt kết nối. Bạn không thể truy cập error khi đã kết nối. Hệ thống type thực thi các ràng buộc của state machine.
Branded Types Cho Domain Safety#
Một pattern nữa tôi thấy thiết yếu — sử dụng branded types để ngăn việc trộn lẫn các giá trị vô tình chia sẻ cùng underlying type:
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Email = Brand<string, "Email">;
function createUserId(id: string): UserId {
return id as UserId;
}
function createOrderId(id: string): OrderId {
return id as OrderId;
}
function getUser(id: UserId): Promise<User> {
// ...
}
function getOrder(id: OrderId): Promise<Order> {
// ...
}
const userId = createUserId("user_123");
const orderId = createOrderId("order_456");
getUser(userId); // OK
// getUser(orderId); // Error! OrderId is not assignable to UserIdCả UserId và OrderId đều là strings tại runtime. Nhưng tại compile time, chúng là các types riêng biệt. Bạn đúng nghĩa không thể truyền order ID ở nơi cần user ID. Điều này đã bắt bugs thực trong mọi dự án tôi đã sử dụng nó.
Các Bẫy Thường Gặp#
Advanced types rất mạnh, nhưng chúng đi kèm với các bẫy. Đây là những gì tôi đã học được theo cách khó khăn nhất.
Giới Hạn Tham Chiếu Vòng#
TypeScript có giới hạn độ sâu đệ quy cho types (hiện tại khoảng 50 cấp, mặc dù nó thay đổi). Nếu bạn đi quá sâu, bạn sẽ gặp lỗi đáng sợ "Type instantiation is excessively deep and possibly infinite."
// Cái này sẽ chạm giới hạn đệ quy cho các đối tượng lồng sâu
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
// Sửa: Thêm bộ đếm độ sâu
type DeepPartialSafe<T, Depth extends number[] = []> =
Depth["length"] extends 10
? T
: {
[K in keyof T]?: T[K] extends object
? DeepPartialSafe<T[K], [...Depth, 0]>
: T[K];
};Thủ thuật bộ đếm độ sâu sử dụng tuple tăng lên với mỗi bước đệ quy. Khi nó chạm giới hạn, đệ quy dừng.
Tác Động Hiệu Năng#
Các types phức tạp có thể làm chậm đáng kể editor và thời gian build. Tôi đã thấy các dự án mà một type quá thông minh thêm 3 giây vào vòng phản hồi mỗi lần nhấn phím.
Dấu hiệu cảnh báo:
- IDE mất hơn 2 giây để hiện autocomplete
tsc --noEmitmất nhiều thời gian hơn đáng kể sau khi thêm một type- Bạn thấy lỗi "Type instantiation is excessively deep"
// Cái này quá thông minh — nó tạo ra bùng nổ tổ hợp
type AllCombinations<T extends string[]> = T extends [
infer First extends string,
...infer Rest extends string[]
]
? `${First}${AllCombinations<Rest>}` | AllCombinations<Rest>
: "";
// Đừng làm điều này với hơn 5-6 phần tửKhi NÀO KHÔNG Nên Dùng Advanced Types#
Đây có thể là phần quan trọng nhất. Advanced types nên được sử dụng khi chúng ngăn bugs hoặc cải thiện trải nghiệm lập trình viên. Chúng không nên được sử dụng để khoe khoang.
Đừng sử dụng khi:
- Một
Record<string, unknown>đơn giản là đủ - Type chỉ được sử dụng ở một chỗ và một concrete type sẽ rõ ràng hơn
- Bạn dành nhiều thời gian debug type hơn thời gian nó tiết kiệm
- Team của bạn không thể đọc hoặc bảo trì nó
- Một kiểm tra runtime sẽ phù hợp hơn
Hãy sử dụng khi:
- Bạn có pattern lặp lại trên nhiều types (mapped types)
- Kiểu trả về của hàm phụ thuộc vào đầu vào (conditional types)
- Bạn đang xây dựng API thư viện cần tự tài liệu hóa
- Bạn muốn làm cho các trạng thái bất hợp pháp không thể biểu diễn
- Bạn mệt mỏi với cùng loại bug xuất hiện trong code reviews
// Quá kỹ thuật — đừng làm điều này cho config đơn giản
type Config = DeepReadonly<
DeepRequired<
Merge<DefaultConfig, Partial<UserConfig>>
>
>;
// Chỉ cần làm thế này
interface Config {
readonly host: string;
readonly port: number;
readonly debug: boolean;
}Quy tắc ngón tay cái của tôi: nếu giải thích type mất nhiều thời gian hơn giải thích bug mà nó ngăn chặn, hãy đơn giản hóa nó.
Debug Các Types Phức Tạp#
Khi một type phức tạp không hoạt động, tôi sử dụng helper này để "nhìn" những gì TypeScript đã resolve:
// Mở rộng type để kiểm tra trong IDE hover tooltips
type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
// Sử dụng nó để debug
type Problematic = SomeComplexType<Input>;
type Debug = Prettify<Problematic>;
// Giờ hover qua Debug trong IDE để xem type đã resolveThủ thuật & {} buộc TypeScript đánh giá type ngay lập tức thay vì hiển thị type alias. Nó đã tiết kiệm cho tôi hàng giờ nhầm lẫn.
Một kỹ thuật khác — cô lập và kiểm tra dần:
// Thay vì debug tất cả cùng lúc:
type Final = StepThree<StepTwo<StepOne<Input>>>;
// Tách nó ra:
type AfterStep1 = StepOne<Input>; // hover để kiểm tra
type AfterStep2 = StepTwo<AfterStep1>; // hover để kiểm tra
type AfterStep3 = StepThree<AfterStep2>; // hover để kiểm traTóm Tắt#
- Conditional types (
T extends U ? X : Y) là if/else cho types. Cẩn thận với hành vi distributive với unions. inferlà pattern matching — sử dụng nó để trích xuất types từ các cấu trúc như function signatures, promises và arrays.- Mapped types (
{ [K in keyof T]: ... }) duyệt qua các properties. Kết hợp vớiascho key remapping và filtering. - Template literal types thao tác chuỗi ở cấp type. Kết hợp với mapped types, chúng cực kỳ mạnh mẽ cho thiết kế API.
- Recursive types xử lý cấu trúc lồng nhau nhưng cần giới hạn độ sâu để tránh bùng nổ compiler.
- Discriminated unions với exhaustive checking là pattern có giá trị cao nhất trong TypeScript. Sử dụng chúng ở mọi nơi.
- Branded types ngăn việc trộn lẫn các giá trị chia sẻ cùng underlying type. Đơn giản để triển khai, tác động cao.
- Đừng quá kỹ thuật hóa types. Nếu type khó hiểu hơn bug mà nó ngăn chặn, hãy đơn giản hóa. Mục tiêu là làm codebase an toàn hơn, không phải thắng cuộc thi type golf.
Hệ thống type TypeScript là Turing complete, nghĩa là bạn có thể làm gần như bất cứ điều gì với nó. Nghệ thuật nằm ở việc biết khi nào bạn nên.