Mapped Types

Mapped Types

Bạn có một interface User với 10 field. Bây giờ bạn cần một version mà tất cả field đều optional để dùng cho update. Bạn copy-paste rồi thêm ? vào từng field? Bạn cần thêm một version nữa mà tất cả field đều readonly? Lại copy-paste?

Đây chính là vấn đề Mapped Types giải quyết: biến đổi hàng loạt kiểu của mọi property trong một object type, chỉ với một công thức duy nhất.

#Vấn đề: Lặp lại kiểu một cách thủ công

// ❌ Thủ công — muốn khóc khi User có 20 field
interface User {
  name: string
  age: number
  email: string
}
 
interface PartialUser {
  name?: string
  age?: number
  email?: string
}
 
interface ReadonlyUser {
  readonly name: string
  readonly age: number
  readonly email: string
}

Mỗi lần User thay đổi, bạn phải cập nhật cả 3 interface. Không maintainable.

#Cú pháp cơ bản: { [K in keyof T]: NewType }

Mapped Types duyệt qua từng key của T và áp dụng một phép biến đổi:

// ✅ Mọi property của T đều trở thành string
type AllString<T> = { [K in keyof T]: string }
 
// Dùng thử
type UserAsString = AllString<User>
// { name: string; age: string; email: string }

Cách đọc: "Với mỗi key K trong tập hợp keyof T, tạo property K với kiểu NewType."

Phân tích từng phần:

  • K in keyof T — duyệt qua union của tất cả key. Nếu T = User thì K lần lượt là "name" | "age" | "email".
  • : NewType — gán kiểu mới cho mỗi property đó.
  • Kết quả là một object type mới có đúng các key cũ nhưng kiểu value khác.

#Modifier: readonly?

TypeScript cho phép bạn thêm hoặc bớt modifier readonly? trong mapped types:

// ✅ Thêm readonly vào mọi property
type MyReadonly<T> = { readonly [K in keyof T]: T[K] }
 
// ✅ Thêm ? (optional) vào mọi property
type MyPartial<T> = { [K in keyof T]?: T[K] }
 
// ✅ Bỏ ? (bắt buộc lại mọi field)
type MyRequired<T> = { [K in keyof T]-?: T[K] }
 
// ✅ Bỏ readonly
type Mutable<T> = { -readonly [K in keyof T]: T[K] }

Dấu - phía trước modifier nghĩa là loại bỏ modifier đó. Không có dấu - nghĩa là thêm modifier.

// ✅ Sử dụng
interface Config {
  readonly host: string
  readonly port: number
}
 
type MutableConfig = Mutable<Config>
// { host: string; port: number }  — readonly đã bị loại bỏ
 
type StrictConfig = Required<{ host?: string; port?: number }>
// { host: string; port: string }  — ? đã bị loại bỏ

#Tự viết lại Partial, Required, Readonly

Đây là cách chính xác mà TypeScript implement các utility type này trong lib.es5.d.ts:

// ✅ Chính xác như Partial<T> built-in
type MyPartial<T> = {
  [K in keyof T]?: T[K]
}
 
// ✅ Chính xác như Required<T> built-in
type MyRequired<T> = {
  [K in keyof T]-?: T[K]
}
 
// ✅ Chính xác như Readonly<T> built-in
type MyReadonly<T> = {
  readonly [K in keyof T]: T[K]
}

Chúng hoạt động vì T[K] giữ nguyên kiểu gốc — bạn chỉ thay đổi modifier, không thay đổi kiểu value.

#Tự viết Pick<T, K>

Pick thú vị hơn: nó không duyệt qua tất cả key, mà chỉ lấy một tập con key:

// ✅ Chỉ lấy các key K từ T
type MyPick<T, K extends keyof T> = {
  [P in K]: T[P]
}
 
// Sử dụng
type UserBasic = MyPick<User, "name" | "email">
// { name: string; email: string }

K extends keyof T là constraint đảm bảo bạn chỉ được pick key có thật trong T. Thử pick key không tồn tại sẽ bị lỗi compile.

// ❌ Lỗi: "phone" không phải key của User
type Invalid = MyPick<User, "name" | "phone">

#Key Remapping với as clause

TypeScript 4.1引入 as clause cho phép bạn đổi tên key trong mapped type:

// ✅ Đổi tên key bằng as
type RenameKeys<T> = {
  [K in keyof T as `new_${string & K}`]: T[K]
}
 
type RenamedUser = RenameKeys<User>
// { new_name: string; new_age: number; new_email: string }

string & K cần thiết vì K có thể là string | number | symbol, nhưng template literal chỉ chấp nhận string.

Một pattern hữu ích: lọc key bằng as bằng cách map sang never:

// ✅ Chỉ giữ key có value là string
type OnlyStringValues<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K]
}
 
type UserStrings = OnlyStringValues<User>
// { name: string; email: string }  — age (number) bị loại

Khi as trả về never, key đó bị loại bỏ khỏi kết quả.

#Kết hợp mapped types với utility types khác

Mapped types phát huy sức mạnh khi kết hợp:

// ✅ DeepReadonly — readonly đệ quy
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object
    ? DeepReadonly<T[K]>
    : T[K]
}
 
interface Nested {
  config: {
    host: string
    settings: {
      debug: boolean
    }
  }
}
 
type FrozenNested = DeepReadonly<Nested>
// config, config.settings, config.settings.debug đều readonly
// ✅ Nullable — biến mọi value thành T | null
type Nullable<T> = { [K in keyof T]: T[K] | null }
 
type NullableUser = Nullable<User>
// { name: string | null; age: number | null; email: string | null }

#Anti-pattern: Overusing mapped types khi type alias đơn giản là đủ

// ❌ Over-engineered — dùng mapped type không cần thiết
type Status = { [K in "loading" | "error" | "success"]: boolean }
 
// ✅ Đơn giản hơn, dễ đọc hơn
type Status = Record<"loading" | "error" | "success", boolean>
 
// Hoặc thậm chí:
type Status = {
  loading: boolean
  error: boolean
  success: boolean
}

Quy tắc: nếu bạn không biến đổi từ một type có sẵn, bạn không cần mapped type. Chỉ dùng mapped types khi:

  1. Bạn có một type T sẵn và muốn tạo version biến đổi từ nó.
  2. Bạn muốn logic thay đổi tự động khi T thay đổi.

Nếu bạn chỉ muốn định nghĩa một object type cố định, viết ra trực tiếp luôn rõ ràng hơn.

#Anti-pattern: Quên constraint khi dùng key remapping

// ❌ Có thể lỗi runtime nếu K không phải string
type Bad<T> = {
  [K in keyof T as `prefix_${K}`]: T[K]
}
 
// ✅ Thêm constraint để an toàn
type Safe<T> = {
  [K in keyof T as `prefix_${string & K}`]: T[K]
}

string & K là pattern bắt buộc khi dùng template literal với key remapping. Thiếu nó, TypeScript sẽ báo lỗi vì keyof T có thể bao gồm number | symbol.

#Tổng kết

  • Mapped Types duyệt qua mỗi key của T và áp dụng phép biến đổi: { [K in keyof T]: ... }.
  • Dùng -readonly-? để loại bỏ modifier; dùng readonly? để thêm modifier.
  • T[K] giữ nguyên kiểu gốc — mapped type chỉ thay đổi modifier hoặc ánh xạ sang kiểu mới.
  • Key remapping với as cho phép đổi tên key hoặc lọc key bằng cách map sang never.
  • Constraint K extends keyof T giúp giới hạn key hợp lệ; string & K cần thiết khi dùng template literal.
  • Đừng dùng mapped type khi type alias đơn giản là đủ — chỉ dùng khi bạn biến đổi từ type có sẵn.

Bài tiếp theo: Template Literal Types — ghép chuỗi ở type level, kết hợp với mapped types để sinh ra các type phức tạp như event handler names và route paths.