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ự.
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 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?"
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 | booleanĐâ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.
Đâ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.
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."
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.
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]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: numbertype ElementOf<T> = T extends (infer E)[] ? E : never;
type Items = ElementOf<string[]>; // string
type Mixed = ElementOf<(string | number)[]>; // string | numberinfer 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 }Đâ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 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.
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 đó.
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 }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.
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 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.
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"Đâ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.
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"); // ErrorBạ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ố.
Đâ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.
Đâ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.
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 ý.
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.
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.
Đâ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.
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.
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 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.
Đâ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 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}`);
}
}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.
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ó.
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.
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.
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:
tsc --noEmit mất nhiều thời gian hơn đáng kể sau khi thêm một type// 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ửĐâ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:
Record<string, unknown> đơn giản là đủHãy sử dụng khi:
// 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ó.
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 extends U ? X : Y) là if/else cho types. Cẩn thận với hành vi distributive với unions.infer là pattern matching — sử dụng nó để trích xuất types từ các cấu trúc như function signatures, promises và arrays.{ [K in keyof T]: ... }) duyệt qua các properties. Kết hợp với as cho key remapping và filtering.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.