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