# TypeScript 的类型映射 ## 简介 映射(mapping)指的是,将一种类型按照映射规则,转换成另一种类型,通常用于对象类型。 举例来说,现有一个类型`A`和另一个类型`B`。 ```typescript type A = { foo: number; bar: number; }; type B = { foo: string; bar: string; }; ``` 上面示例中,这两个类型的属性结构是一样的,但是属性的类型不一样。如果属性数量多的话,逐个写起来就很麻烦。 使用类型映射,就可以从类型`A`得到类型`B`。 ```typescript type A = { foo: number; bar: number; }; type B = { [prop in keyof A]: string; }; ``` 上面示例中,类型`B`采用了属性名索引的写法,`[prop in keyof A]`表示依次得到类型`A`的所有属性名,然后将每个属性的类型改成`string`。 在语法上,`[prop in keyof A]`是一个属性名表达式,表示这里的属性名需要计算得到。具体的计算规则如下: - `prop`:属性名变量,名字可以随便起。 - `in`:运算符,用来取出右侧的联合类型的每一个成员。 - `keyof A`:返回类型`A`的每一个属性名,组成一个联合类型。 下面是复制原始类型的例子。 ```typescript type A = { foo: number; bar: string; }; type B = { [prop in keyof A]: A[prop]; }; ``` 上面示例中,类型`B`原样复制了类型`A`。 为了增加代码复用性,可以把常用的映射写成泛型。 ```typescript type ToBoolean = { [Property in keyof Type]: boolean; }; ``` 上面示例中,定义了一个泛型,可以将其他对象的所有属性值都改成 boolean 类型。 下面是另一个例子。 ```typescript type MyObj = { [P in 0|1|2]: string; }; // 等同于 type MyObj = { 0: string; 1: string; 2: string; }; ``` 上面示例中,联合类型`0|1|2`映射成了三个属性名。 不使用联合类型,直接使用某种具体类型进行属性名映射,也是可以的。 ```typescript type MyObj = { [p in 'foo']: number; }; // 等同于 type MyObj = { foo: number; }; ``` 上面示例中,`p in 'foo'`可以看成只有一个成员的联合类型,因此得到了只有这一个属性的对象类型。 甚至还可以写成`p in string`。 ```typescript type MyObj = { [p in string]: boolean; }; // 等同于 type MyObj = { [p: string]: boolean; }; ``` 上面示例中,`[p in string]`就是属性名索引形式`[p: string]`的映射写法。 通过映射,可以把某个对象的所有属性改成可选属性。 ```typescript type A = { a: string; b: number; }; type B = { [Prop in keyof A]?: A[Prop]; }; ``` 上面示例中,类型`B`在类型`A`的所有属性名后面添加问号,使得这些属性都变成了可选属性。 事实上,TypeScript 的内置工具类型`Partial`,就是这样实现的。 TypeScript内置的工具类型`Readonly`可以将所有属性改为只读属性,实现也是通过映射。 ```typescript // 将 T 的所有属性改为只读属性 type Readonly = { readonly [P in keyof T]: T[P]; }; ``` 它的用法如下。 ```typescript type T = { a: string; b: number }; type ReadonlyT = Readonly; // { // readonly a: string; // readonly b: number; // } ``` ## 映射修饰符 映射会原样复制原始对象的可选属性和只读属性。 ```typescript type A = { a?: string; readonly b: number; }; type B = { [Prop in keyof A]: A[Prop]; }; // 等同于 type B = { a?: string; readonly b: number; }; ``` 上面示例中,类型`B`是类型`A`的映射,把`A`的可选属性和只读属性都保留下来。 如果要删改可选和只读这两个特性,并不是很方便。为了解决这个问题,TypeScript 引入了两个映射修饰符,用来在映射时添加或移除某个属性的`?`修饰符和`readonly`修饰符。 - `+`修饰符:写成`+?`或`+readonly`,为映射属性添加`?`修饰符或`readonly`修饰符。 - `–`修饰符:写成`-?`或`-readonly`,为映射属性移除`?`修饰符或`readonly`修饰符。 下面是添加或移除可选属性的例子。 ```typescript // 添加可选属性 type Optional = { [Prop in keyof Type]+?: Type[Prop]; }; // 移除可选属性 type Concrete = { [Prop in keyof Type]-?: Type[Prop]; }; ``` 注意,`+?`或`-?`要写在属性名的后面。 下面是添加或移除只读属性的例子。 ```typescript // 添加 readonly type CreateImmutable = { +readonly [Prop in keyof Type]: Type[Prop]; }; // 移除 readonly type CreateMutable = { -readonly [Prop in keyof Type]: Type[Prop]; }; ``` 注意,`+readonly`和`-readonly`要写在属性名的前面。 如果同时增删`?`和`readonly`这两个修饰符,写成下面这样。 ```typescript // 增加 type MyObj = { +readonly [P in keyof T]+?: T[P]; }; // 移除 type MyObj = { -readonly [P in keyof T]-?: T[P]; } ``` TypeScript 原生的工具类型`Required`专门移除可选属性,就是使用`-?`修饰符实现的。 注意,`–?`修饰符移除了可选属性以后,该属性就不能等于`undefined`了,实际变成必选属性了。但是,这个修饰符不会移除`null`类型。 另外,`+?`修饰符可以简写成`?`,`+readonly`修饰符可以简写成`readonly`。 ```typescript type A = { +readonly [P in keyof T]+?: T[P]; }; // 等同于 type A = { readonly [P in keyof T]?: T[P]; }; ``` ## 键名重映射 ### 语法 TypeScript 4.1 引入了键名重映射(key remapping),允许改变键名。 ```typescript type A = { foo: number; bar: number; }; type B = { [p in keyof A as `${p}ID`]: number; }; // 等同于 type B = { fooID: number; barID: number; }; ``` 上面示例中,类型`B`是类型`A`的映射,但在映射时把属性名改掉了,在原始属性名后面加上了字符串`ID`。 可以看到,键名重映射的语法是在键名映射的后面加上`as + 新类型`子句。这里的“新类型”通常是一个模板字符串,里面可以对原始键名进行各种操作。 下面是另一个例子。 ```typescript interface Person { name: string; age: number; location: string; } type Getters = { [P in keyof T as `get${Capitalize}`]: () => T[P]; }; type LazyPerson = Getters; // 等同于 type LazyPerson = { getName: () => string; getAge: () => number; getLocation: () => string; } ``` 上面示例中,类型`LazyPerson`是类型`Person`的映射,并且把键名改掉了。 它的修改键名的代码是一个模板字符串`get${Capitalize}`,下面是各个部分的解释。 - `get`:为键名添加的前缀。 - `Capitalize`:一个原生的工具泛型,用来将`T`的首字母变成大写。 - `string & P`:一个交叉类型,其中的`P`是 keyof 运算符返回的键名联合类型`string|number|symbol`,但是`Capitalize`只能接受字符串作为类型参数,因此`string & P`只返回`P`的字符串属性名。 ### 属性过滤 键名重映射还可以过滤掉某些属性。下面的例子是只保留字符串属性。 ```typescript type User = { name: string, age: number } type Filter = { [K in keyof T as T[K] extends string ? K : never]: string } type FilteredUser = Filter // { name: string } ``` 上面示例中,映射`K in keyof T`获取类型`T`的每一个属性以后,然后使用`as Type`修改键名。 它的键名重映射`as T[K] extends string ? K : never]`,使用了条件运算符。如果属性值`T[K]`的类型是字符串,那么属性名不变,否则属性名类型改为`never`,即这个属性名不存在。这样就等于过滤了不符合条件的属性,只保留属性值为字符串的属性。 ### 联合类型的映射 由于键名重映射可以修改键名类型,所以原始键名的类型不必是`string|number|symbol`,任意的联合类型都可以用来进行键名重映射。 ```typescript type S = { kind: 'square', x: number, y: number, }; type C = { kind: 'circle', radius: number, }; type MyEvents = { [E in Events as E['kind']]: (event: E) => void; } type Config = MyEvents; // 等同于 type Config = { square: (event:S) => void; circle: (event:C) => void; } ``` 上面示例中,原始键名的映射是`E in Events`,这里的`Events`是两个对象组成的联合类型`S|C`。所以,`E`是一个对象,然后再通过键名重映射,得到字符串键名`E['kind']`。 ## 参考链接 - [Mapped Type Modifiers in TypeScript](https://mariusschulz.com/blog/mapped-type-modifiers-in-typescript), Marius Schulz