TypeScript 的类型系统非常强大,它允许用户构建各种自定义类型来应对各种复杂的场景,本文我们将深入探讨 TypeScript 类型编程这一主题。
类型捕获
typeof
可通过 typeof
操作符捕获变量
、对象属性
的类型,比如下面的例子:
let foo = 123;
let bar: typeof foo;
bar = 456;
bar = '789'; // 不能将类型“string”分配给类型“number”。ts(2322)
let o = {
v: 123,
};
let bar1: typeof o.v;
bar1 = 456;
bar1 = '789'; // 不能将类型“string”分配给类型“number”。ts(2322)
复制代码
编译上述代码,变量 bar
与 foo
、bar1
与 o.v
的类型相同,都是 number
;再看下面的例子:
const foo = 123;
let bar: typeof foo; // 'bar' 的类型为字面值类型:123
bar = 456; // 不能将类型“456”分配给类型“123”。ts(2322)
复制代码
编译上述代码,我们会得到不能将类型“456”分配给类型“123”
的错误,这是因为我们将变量 foo
设置为了常量
,根据 TypeScript 的类型推倒,变量 foo
的类型为字面量类型 123
,此刻除了 123
,不能将其它任何值赋予变量 bar
,可显式
声明 foo
的类型来改变这种行为,比如下面的代码:
const foo: number = 123;
let bar: typeof foo;
bar = 456;
复制代码
再次编译,此刻编译器一路绿灯。这也是在编码过程中大家经常碰到的小问题,知道了缘由,相信大家能够有效地避免与解决类似问题。
keyof
可通过 keyof
关键字提取对象属性名
,比如下面的例子:
interface Colors {
red: string;
blue: string;
}
let color: keyof Colors; // color 的类型是 'red' | 'blue'
color = 'red'; // ok
color = 'blue'; // ok
color = 'anythingElse'; // ts(2322)
复制代码
上述代码中,变量 color
的类型被限定在 'red' | 'blue'
,所以我们不能将值 anythingElse
赋予变量 color
。
泛型
通过将类型参数化,我们可以将多个类型中具有某一共同行为的逻辑进行抽象,以达到代码重用的目的。本文我们仅对 TypeScript 泛型中的类型编程进行阐述,关于泛型的更多信息参见 TypeScript 泛型。
extends
可通过 extends
关键字来判断两个类型的父子类型(父子类型的讨论可参见笔者的另一篇文章:TypeScript 类型兼容性),比如下面的例子:
type isSubTyping<Child, Par> = Child extends Par ? true : false;
type isSubNumber = isSubTyping<1, number>; // true
type isSubString = isSubTyping<'string', string>; // true
复制代码
分配条件类型
所谓分配条件类型
是指:在使用 extends
关键字对类型进行条件判断时,如果入参
是联合类型,那么入参
会被拆解为一个个独立的类型
来进行类型运算
。比如下面的例子:
type BooleanOrString = string | boolean;
type StringOrNumberArray<E> = E extends string | number ? E[] : E;
type BoolOrStringArray = StringOrNumberArray<BooleanOrString>; // boolean | string[]
复制代码
上述代码中,BoolOrStringArray
的类型为 boolean | string[]
,这是因为按照分配条件类型
的规则,我们将 BooleanOrString
拆分成 string
和 boolean
,然后依次作为 StringOrNumberArray
的参数
进行匹配:
- 因为
string extends string | number
为true
,所以返回string[]
; - 因为
boolean extends string | number
为false
,所以返回boolean
; - 最后将前两步的返回值合并,即得到了
string[]|boolean
。
我们再看下面的例子:
type BooleanOrStringType = BooleanOrString extends string | number ? BooleanOrString[] : BooleanOrString // string | boolean
复制代码
上述代码中,我们通过直接内联 StringOrNumberArray
内部逻辑,而不是通过间接调用 StringOrNumberArray<BooleanOrString>
的方式来定义 BooleanOrStringType
,此刻 BooleanOrStringType
的类型变成了 string | boolean
,这是因为分配条件类型
仅在泛型
中有效,在非泛型
的情况下,BooleanOrString
将会当作一个整体
来参与运算。
infer
在类型(常见于泛型类型)定义中,有时候我们需要根据入参
的类型推断出类型定义
语句中的某个类型,此时便可使用 infer
关键字来标识这个等待推断的类型变量,比如下面的类型定义:
type ParamType<T> = T extends (...args: infer P) => any ? P : T;
复制代码
上述代码中,我们定义了一个自动获取参数类型的新类型 ParamType<T>
,根据语句定义可知,在 (...args: infer P) => any
的定义中,我们无法获知参数类型 P
的具体类型是什么,因此可通过 infer
来修饰类型 P
,以便告诉编译器在入参 T
满足函数定义规则时,自动推导出相关类型。比如下面的例子:
interface Person {
name: string;
}
type PersonFunc = (person: Person) => void;
type A = ParamType<PersonFunc>; // [person: Person]
type B = ParamType<string>; // string
复制代码
上述代码中,我们可推断出 A
的类型为 [person: Person]
,B
的类型为 string
。
索引签名
在进行类型定义时,有时候我们无法穷举该类型的所有属性,又不想将其定义为 any
,此时便可使用索引签名
来完成类型定义:
interface Messages {
[type: string]: {
message: string;
};
}
复制代码
上述代码中,我们使用 [type: string]
(type
只是标识符,可替换成你需要的任何名字)来完成 Messages
属性的定义,这种定义类型属性的方式,我们称之为索引签名
。通过此方式,既解决了无法穷举类型属性的问题,也解决了使用 any
所引发的安全性降低的问题。比如下面的例子:
let messages: Messages = {};
messages['one'] = { message: 'one message' };
messages['two'] = { msg: 'two message' }; // 不能将类型“{ msg: string; }”分配给类型“{ message: string; }”。对象文字可以只指定已知属性,并且“msg”不在类型“{ message: string; }”中。ts(2322)
console.log(messages['one'].message);
console.log(messages['two'].msg); // 类型“{ message: string; }”上不存在属性“msg”。ts(2339)
复制代码
上述代码中,我们可以为 messages
动态设置属性,并且在属性设置与读取的过程中,都会对属性的值进行安全的类型检测。
索引签名
在类型定义中给我们带来了诸多好处,但也需要考虑它与明确成员之间的兼容性
,比如下面的例子:
interface Foo {
[key: string]: number;
x: string; // 类型“string”的属性“x”不能赋给“string”索引类型“number”。ts(2411)
}
复制代码
上述代码中,我们声明了一个字符串索引签名,以及一个明确的成员 x
,编译代码将会抛出 ts(2411)
异常,编译器如此处理的目的是为了保证类型的安全性。因为如果通过索引的形式访问 x
时,谁也无法保证 x
的值转换成 number
后与 x
本身的值相同(即并非所有的 string
都能转换成对应的 number
)。
映射类型
映射类型
是建立在索引签名
的语法上,利用映射类型
可基于一个类型来构造出另外一个类型。比如下面的例子:
type AnyType<T> = {
[key in keyof T]: any;
}
复制代码
在 AnyType<T>
的定义语句中,我们在索引签名语法
的基础上使用了 in
及 keyof
操作符,也由于通过 AnyType<T>
可以将入参类型 T
构造出一个新的类型 T1
,因此 AnyType<T>
可以称为映像类型
。比如下面的例子:
interface Person {
name: string;
age: number;
address: string;
}
type AnyPerson = AnyType<Person>;
复制代码
分析上述代码可知 AnyPerson
等同于:
interface AnyPerson {
name: any;
age: any;
address: any;
}
复制代码
需要注意的是,in
只能在类型别名中使用,如果在接口中使用,将抛出异常,比如:
type PersonKeys = 'name' | 'age' | 'address';
interface AnyPerson {
[key in PersonKeys]: any; // 接口中的计算属性名称必须引用必须引用类型为文本类型或 "unique symbol" 的表达式。ts(1169)
};
复制代码
实战
前面我们讨论了 TypeScript 类型编程所需的前备知识,本节我们通过分析一些常见的类型定义来感受 TypeScript 类型系统的强大。
Exclude
通过 Exclude<T, U>
我们可以排除掉入参 T
中的子类型成员 U
:
type Exclude<T, U> = T extends U ? never : T;
type A = Exclude<number | string, number>; // string
复制代码
上述代码中, Exclude<T, U>
利用了分配条件类型
的特性,根据规则可推断出:
A
的类型为never|string
;- 由于
never
是所有类型的子类型
,故可将never|string
进行类型缩减,最终得到A
的类型为string
。
ReturnType
通过 ReturnType<T>
我们可以获得一个方法的返回值类型:
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
type A = ReturnType<() => string>; // string
复制代码
上述代码中,ReturnType<T>
使用了 infer
关键字,根据规则可推出 A
类型为 string
。
Required
通过 Required<T>
我们可将入参 T
的所有属性设置为必要属性:
type Required<T> = {
[P in keyof T]-?: T[P];
};
interface Person {
name?: string;
age?: string;
}
type RequiredPerson = Required<Person>;
复制代码
在 Required<T>
的定义中,我们使用了映射类型
,并且在键值的后面使用了 -
号,-
与 ?
的组合表示去除
可选属性,因此可推断出 RequiredPerson
的类型相当于:
interface RequiredPerson {
name: string;
age: string;
}
复制代码
另外,通过 -
我们亦可去除属性的 readonly
属性:
type Changeable<T> = {
-readonly [P in keyof T]: T[P];
};
interface ReadonlyPerson {
readonly name: string;
readonly age: string;
}
type ChangeablePerson = Changeable<ReadonlyPerson>;
复制代码
其中,ChangeablePerson
相当于:
interface ChangeablePerson {
name: string;
age: string;
}
复制代码
Pick
通过 Pick<T, K>
我们可以从入参 T
中取出指定的键值,然后组成一个新的类型:
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
interface Person {
name: string;
age: number;
address: string;
}
type PersonWithoutAge = Pick<Person, 'name' | 'address'>;
复制代码
在 Pick<T, K>
的定义中,入参 K
的类型被约束为入参 T
的键的子类型,又根据映射类型
的规则,故可推断出 PersonWithoutAge
的结果相当于:
interface PersonWithoutAge {
name: string;
address: string;
}
复制代码
上面我们分析了一些常用的工具类型,除此之外,TypeScript 内置了许许多多的工具类型,此处不再一一阐述,大家可自行进行分析。
总结
本文我们首先介绍了类型捕获中的 typeof 和 keyof,然后讨论了泛型中的 extends、分配条件类型及 infer,接着对索引签名、映射类型进行了阐述,最后通过实际例子进一步巩固了前面的知识点。通过本文,相信大家对 TypeScript 的类型编程有了较为深入的理解,在以后的实践中,相信大家可以轻松定义出应对各种复杂场景的类型系统,以便为构建强壮、安全的应用提供良好的保障。