TypeScript mapped types 深度运用:类型变换与键重映射

HTMLPAGE 团队
17 分钟阅读

Mapped types 是 TypeScript 类型变换的核心工具。本文从映射语法、键重映射(as 子句)、键值过滤到实用工具类型的实现,系统讲清 mapped types 在各种场景下的使用方式和边界。

#TypeScript #mapped types #类型变换 #键重映射 #工具类型

Mapped types 是 TypeScript 类型系统里使用频率最高但理解深度最浅的特性之一。大多数人会用 Partial<T>Required<T>Readonly<T>,但很少想过这些内置工具类型是怎么实现的。实际 mapped types 不只能做这些基础变换——它还可以对键名做重映射、按值类型过滤键、给对象属性做加减乘除。

映射语法的基础

一个 mapped type 的基本结构是这样:

type Mapped<T> = {
  [K in keyof T]: T[K]
}

[K in keyof T] 遍历 T 的所有键,右侧 T[K] 取对应值类型。这是最简单的恒等映射——输入什么类型就输出什么类型。

基于这个结构,可以添加各种修饰符:

type MyReadonly<T> = {
  readonly [K in keyof T]: T[K]
}

type MyPartial<T> = {
  [K in keyof T]?: T[K]
}

type MyRequired<T> = {
  [K in keyof T]-?: T[K]
}

? 把属性变成可选,readonly 加上只读。减号 -?-readonly 是去掉这些修饰符——内置的 Required<T>Mutable<T> 就是靠减号实现的。

键重映射(Key Remapping)

TypeScript 4.1 引入的 as 子句让 mapped types 能重命名键,而不只是修改键上的类型:

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}

type Person = { name: string; age: number }
type PersonGetters = Getters<Person>
// { getName: () => string; getAge: () => number }

这里的 as \get${Capitalize<string & K>}`` 做了三件事:

  1. string & K 确保 K 是字符串(symbol 和其他值类型的键被排除)
  2. Capitalize 把首字母大写
  3. 模板字面量拼接出 getXxx 的键名

如果没有 as 子句,mapped types 只能改值类型,不能改键名。

按值类型过滤键

as 子句可以返回 never 来排除某些键:

type FunctionsOnly<T> = {
  [K in keyof T as T[K] extends Function ? K : never]: T[K]
}

type Obj = { name: string; greet: () => void; age: number }
type FnProps = FunctionsOnly<Obj>
// { greet: () => void }

type NonFunctions<T> = {
  [K in keyof T as T[K] extends Function ? never : K]: T[K]
}

type NonFnProps = NonFunctions<Obj>
// { name: string; age: number }

as never 意味着这个键不出现在输出类型里。这不是把值类型改成 never,而是完全剔除这个键。

前缀添加与移除

// 添加前缀
type AddPrefix<T, P extends string> = {
  [K in keyof T as `${P}${string & K}`]: T[K]
}

type WithPrefix = AddPrefix<{ name: string }, 'user_'>
// { user_name: string }

// 移除前缀
type RemovePrefix<T, P extends string> = {
  [K in keyof T as K extends `${P}${infer R}` ? R : never]: T[K]
}

type Unprefixed = RemovePrefix<{ user_name: string; user_age: number }, 'user_'>
// { name: string; age: number }

下划线转驼峰

结合模板字面量的递归,可以对键做大小写转换:

type SnakeToCamel<S extends string> =
  S extends `${infer P}_${infer Q}`
    ? `${P}${Capitalize<SnakeToCamel<Q>>}`
    : S

type CamelCaseKeys<T> = {
  [K in keyof T as SnakeToCamel<string & K>]: T[K]
}

type SnakeObj = { first_name: string; last_name: string }
type CamelObj = CamelCaseKeys<SnakeObj>
// { firstName: string; lastName: string }

实用工具类型的实现

PickByValue:按值类型筛选

type PickByValue<T, V> = {
  [K in keyof T as T[K] extends V ? K : never]: T[K]
}

type Obj2 = { a: string; b: number; c: string; d: boolean }
type OnlyString = PickByValue<Obj2, string>
// { a: string; c: string }

DeepPartial:递归可选化

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

type Config = { server: { host: string; port: number }; db: { url: string } }
type PartialConfig = DeepPartial<Config>
// { server?: { host?: string; port?: number }; db?: { url?: string } }

DeepPartial 在 form 初始化、配置合并等场景特别有用。注意这里对数组做了单独处理,避免把数组 map 成一个对象类型。

NonNullableFields:去掉可空字段

type NonNullableFields<T> = {
  [K in keyof T]-?: NonNullable<T[K]>
}

type Nullable = { a: string | null; b: number | undefined }
type Clean = NonNullableFields<Nullable>
// { a: string; b: number }

减号 -? 去掉了可选标志,NonNullable<T[K]> 去掉了 nullundefined

mapped types 的几个陷阱

1. keyof 和 as 对 symbol 键的处理

type WithSymbol = { [key: string]: number; [Symbol.iterator]: () => void }

type StringKeysOnly<T> = {
  [K in keyof T as K extends string ? K : never]: T[K]
}

不这么做的话,string & K 会把 symbol 键转为 never

2. 映射类型不会保留某些类型信息

type MyPick<T, K extends keyof T> = {
  [P in K]: T[P]
}

// 如果 T 有可选属性,映射后的类型不会保留"可选"属性上的 undefined 合并行为

更好的做法是用内置的 Pick<T, K>——它做了更多 edge case 处理。

3. 递归 mapped types 的类型实例化开销

每层嵌套的 DeepPartial 都会增加实例化深度。对于超过 10 层嵌套的对象,考虑限制递归深度或使用扁平化方案:

type DeepPartialSimple<T> = {
  [K in keyof T]?: T[K] extends Record<string, any>
    ? DeepPartialSimple<T[K]>
    : T[K]
}

控制递归深度的一个方式是按层级拆分:

type DeepPartial2<T> = {
  [K in keyof T]?: // 只展开第一层
    T[K] extends object
      ? // 为深层对象专门定义的类型
      : T[K]
}

mapped types 的典型应用场景

场景实现备注
给所有属性加 readonly{ readonly [K in keyof T]: T[K] }内置 Readonly
所有属性转为可选{ [K in keyof T]?: T[K] }内置 Partial
键名加前缀[K in keyof T as \prefix_${K}`]`需处理非 string 键
按值过滤键as T[K] extends V ? K : never可用于 PickByValue
递归不可变递归调用 mapped type注意深度限制
键名转换as Capitalize<string & K>大小写、驼峰等

总结

Mapped types 的核心能力有三个:遍历键、修饰属性值、重映射键。理解这三层之后,大部分内置工具类型的实现都能读懂。不要试图在一个 mapped type 里做完所有事情——分拆成多个步骤更容易协作和维护。比如先做值类型过滤,再做键重映射,最后加修饰符。每个步骤一个类型别名,组合起来反而比复杂的单步类型更清晰。