Skip to content
⚠️ This article was written in 2019. Some content may be outdated.

TypeScript 對映型別深入

TypeScript 的型別系統非常強大,對映型別(Mapped Types)是其中最實用的特性之一。它允許你基於已有型別建立新型別,批次修改屬性的修飾符。這篇文章從基礎到實戰,把對映型別講清楚。

基礎語法

對映型別的核心語法:

typescript
type MappedType<T> = {
  [K in keyof T]: T[K]
}
  • keyof T:獲取 T 的所有鍵,返回聯合型別
  • K in keyof T:遍歷每個鍵
  • T[K]:獲取 T 中鍵 K 對應的型別(索引訪問型別)

看一個最簡單的例子:

typescript
interface User {
  id: number
  name: string
  email: string
  age: number
}

// 把所有屬性變為 string 型別
type StringifyUser = {
  [K in keyof User]: string
}
// 等價於:
// {
//   id: string
//   name: string
//   email: string
//   age: string
// }

只讀修飾符:readonly +/-

加號 + 表示新增修飾符(預設行為,可省略),減號 - 表示移除修飾符:

typescript
// 新增 readonly(所有屬性變為只讀)
type ReadonlyUser = {
  readonly [K in keyof User]: User[K]
}
// 等價於:
// interface ReadonlyUser {
//   readonly id: number
//   readonly name: string
//   readonly email: string
//   readonly age: number
// }

// 移除 readonly
type MutableUser = {
  -readonly [K in keyof ReadonlyUser]: ReadonlyUser[K]
}

// TypeScript 內建的 Readonly<T> 就是這樣實現的:
// type Readonly<T> = {
//   readonly [P in keyof T]: T[P]
// }

可選性修飾符:? +/-

typescript
// 所有屬性變為可選
type PartialUser = {
  [K in keyof User]?: User[K]
}
// 等價於內建的 Partial<T>

// 移除可選性(所有屬性變為必填)
type RequiredUser = {
  [K in keyof PartialUser]-?: PartialUser[K]
}
// 等價於內建的 Required<T>

與條件型別結合

對映型別配合條件型別可以做更精確的型別變換:

typescript
// 只把函式型別的屬性提取出來
type FunctionKeys<T> = {
  [K in keyof T]: T[K] extends Function ? K : never
}[keyof T]

interface Api {
  baseUrl: string
  timeout: number
  getUsers: () => Promise<User[]>
  deleteUser: (id: number) => Promise<void>
  version: string
}

type ApiFunctionKeys = FunctionKeys<Api>
// "getUsers" | "deleteUser"
typescript
// 把所有方法的返回值型別提取出來
type ReturnTypes<T> = {
  [K in keyof T]: T[K] extends (...args: any[]) => infer R ? R : never
}

type ApiReturnTypes = ReturnTypes<Api>
// {
//   baseUrl: never
//   timeout: never
//   getUsers: Promise<User[]>
//   deleteUser: Promise<void>
//   version: never
// }
typescript
// 只保留函式屬性
type PickFunctions<T> = {
  [K in keyof T as T[K] extends Function ? K : never]: T[K]
}
// 注意:as 語法在 TS 4.1+ 中可用,3.x 中需要用不同的方式

實戰:實現常用工具型別

DeepPartial — 深層可選

內建的 Partial 只處理第一層,巢狀物件不會被處理:

typescript
interface Config {
  database: {
    host: string
    port: number
    credentials: {
      username: string
      password: string
    }
  }
  cache: {
    ttl: number
    maxSize: number
  }
}

// Partial<Config> 只能讓 database 和 cache 可選
// 但 database.host、database.port 還是必填

type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object
    ? T[K] extends Function
      ? T[K]
      : DeepPartial<T[K]>
    : T[K]
}

// 使用
const partialConfig: DeepPartial<Config> = {
  database: {
    host: 'localhost'
    // port、credentials 都可以省略
  }
  // cache 也可以省略
}

DeepReadonly — 深層只讀

typescript
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object
    ? T[K] extends Function
      ? T[K]
      : DeepReadonly<T[K]>
    : T[K]
}

const config: DeepReadonly<Config> = getConfig()
config.database.host = 'new-host' // 報錯:只讀
config.database = { host: 'new', port: 3306 } // 報錯:只讀

Record 的實現

Record<K, V> 是 TypeScript 內建的工具型別,把聯合型別 K 對映為值型別 V:

typescript
// 內建實現
type Record<K extends keyof any, T> = {
  [P in K]: T
}

// 使用
type Status = 'pending' | 'processing' | 'completed' | 'failed'

const statusLabels: Record<Status, string> = {
  pending: '待處理',
  processing: '處理中',
  completed: '已完成',
  failed: '已失敗'
}
// 如果缺少任何一個狀態,TypeScript 會報錯

Pick 和 Omit 的實現

typescript
// Pick:從 T 中選取指定的屬性
type MyPick<T, K extends keyof T> = {
  [P in K]: T[P]
}

type UserBasic = Pick<User, 'id' | 'name'>
// { id: number, name: string }

// Omit:從 T 中排除指定的屬性
type MyOmit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>

type UserWithoutAge = Omit<User, 'age'>
// { id: number, name: string, email: string }

實戰:API 響應型別生成

實際專案中,後端 API 返回的資料往往有統一的包裝結構:

typescript
// API 響應的統一結構
interface ApiResponse<T> {
  code: number
  message: string
  data: T
}

// 使用者相關的資料型別
interface UserData {
  id: number
  name: string
  email: string
  role: 'admin' | 'editor' | 'viewer'
}

// 使用
async function getUser(id: number): Promise<ApiResponse<UserData>> {
  const response = await fetch(`/api/users/${id}`)
  return response.json()
}

// 如果想要剝離包裝,只取 data 部分:
type UnwrapResponse<T> = T extends ApiResponse<infer U> ? U : T

async function getUserData(id: number): Promise<UserData> {
  const res = await getUser(id)
  return res.data
}

實戰:Vuex/Redux Action 型別生成

從 action 處理函式自動生成 action 型別:

typescript
// 定義 action handlers
const userActions = {
  SET_USER(state: UserState, user: User) {
    state.user = user
  },
  SET_LOADING(state: UserState, loading: boolean) {
    state.loading = loading
  },
  SET_ERROR(state: UserState, error: string | null) {
    state.error = error
  }
}

// 提取 action 名稱
type ActionNames = keyof typeof userActions
// "SET_USER" | "SET_LOADING" | "SET_ERROR"

// 提取每個 action 的 payload 型別
type ActionPayloads = {
  [K in ActionNames]: Parameters<typeof userActions[K]>[1]
}
// {
//   SET_USER: User
//   SET_LOADING: boolean
//   SET_ERROR: string | null
// }

踩坑記錄

坑 1:遞迴型別導致無限迴圈

typescript
// 遞迴深度太大時 TypeScript 可能報錯
type DeepPartial<T> = {
  [K in keyof T]?: DeepPartial<T[K]> // 無限遞迴的型別
}

// 解決:加上函式型別判斷的終止條件
type DeepPartial<T> = T extends Function
  ? T
  : {
      [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K]
    }

坑 2:對映型別丟失方法簽名

typescript
interface Service {
  data: string
  getData(): string
}

type Wrapped = {
  [K in keyof Service]: Service[K]
}
// getData 的型別會被保留為 () => string
// 但如果用了泛型變換(如變為 Promise 包裹),需要注意方法型別

坑 3:索引簽名的處理

typescript
interface Dictionary {
  [key: string]: number
}

type ReadonlyDict = Readonly<Dictionary>
// 索引簽名會被保留:readonly [key: string]: number

小結

  • 對映型別的核心語法:[K in keyof T]: T[K]
  • readonly? 修飾符可以用 + 新增、- 移除
  • 對映型別 + 條件型別 = 強大的型別變換能力
  • PartialRequiredReadonlyPickOmitRecord 都基於對映型別實現
  • DeepPartialDeepReadonly 等遞迴型別要注意終止條件
  • 實際專案中常用於 API 型別生成、狀態管理型別推導等場景

MIT Licensed