Đi đến nội dung
·23 phút đọc

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.

Chia sẻ:X / TwitterLinkedIn

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:

typescript
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:

typescript
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:

typescript
type IsString<T> = T extends string ? true : false;
 
type A = IsString<"hello">; // true
type B = IsString<42>;      // false
type C = IsString<string>;  // true

Từ 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ỏ nullundefined khỏi một union:

typescript
type MyNonNullable<T> = T extends null | undefined ? never : T;
 
type Test = MyNonNullable<string | null | undefined>;
// Result: string

Type 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:

typescript
type MyExtract<T, U> = T extends U ? T : never;
 
type Numbers = Extract<string | number | boolean, number | boolean>;
// Result: number | boolean

Exclude — ngược lại, loại bỏ các thành viên khớp:

typescript
type MyExclude<T, U> = T extends U ? never : T;
 
type WithoutStrings = Exclude<string | number | boolean, string>;
// Result: number | boolean

Distributive 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ẻ.

typescript
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 unknownnumber extends unknown riêng biệt, rồi union các kết quả.

Đây là lý do tại sao ExtractExclude 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:

typescript
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:

typescript
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: string

Cá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#

typescript
type MyReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
 
type Fn = (x: number, y: string) => boolean;
type Result = MyReturnType<Fn>; // boolean

TypeScript 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#

typescript
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:

typescript
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:

typescript
type DeepUnpackPromise<T> = T extends Promise<infer U>
  ? DeepUnpackPromise<U>
  : T;
 
type Deep = DeepUnpackPromise<Promise<Promise<Promise<number>>>>;
// Result: number

Trích Xuất Kiểu Phần Tử Mảng#

typescript
type ElementOf<T> = T extends (infer E)[] ? E : never;
 
type Items = ElementOf<string[]>;         // string
type Mixed = ElementOf<(string | number)[]>; // string | number

Nhiề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:

typescript
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:

typescript
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#

typescript
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:

typescript
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ể:

typescript
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ể:

typescript
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ể:

typescript
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:

typescript
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:

typescript
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:

typescript
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#

typescript
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:

typescript
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:

typescript
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:

typescript
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");  // Error

Phâ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:

typescript
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ẽ:

typescript
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:

typescript
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:

typescript
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: string

Autocomplete đầ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:

typescript
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 optional

Lư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:

typescript
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:

typescript
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:

typescript
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:

typescript
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 missing

Phươ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:

typescript
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 AppEvents

Tô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:

typescript
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:

typescript
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:

typescript
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:

typescript
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 UserId

Cả UserIdOrderId đề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."

typescript
// 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 --noEmit mấ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"
typescript
// 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
typescript
// 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:

typescript
// 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 đã resolve

Thủ 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:

typescript
// 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 tra

Tó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.
  • 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.
  • Mapped types ({ [K in keyof T]: ... }) duyệt qua các properties. Kết hợp với as cho 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.

Bài viết liên quan