Z

深入TypeScript类型系统

TypeScript是JavaScript的超集,其价值在于为JavaScript带来了类型系统,对于大型工程,类型系统的重要性不言而喻,有句老话不是说得好“动态类型一时爽,代码重构火葬场”。动态类型在开发时确实方便,但它的核心问题是仅从代码来看,你根本无法知道变量实际上是什么类型的,这就会造成一个经常会出现的错误:“undefined is not a function”。

在前端工程中,使用TypeScript的一个核心痛点是,最开始写类型时快快乐乐的,什么东西都要确定类型,但到了项目后期,就变成any满天飞了。所以本文的目的是深入探究类型系统在前端工程实践时的用法,尽可能避免后期any满天飞的场景。

类型保护与断言

类型保护与断言出现在使用联合类型(Union Type)场景下,在TypeScript中,联合类型表示该类型可能是若干类型中的一个,例如:

1interface IA {
2    name: String;
3}
4
5interface IB {
6    age: Number
7}
8
9type S = IA | IB;

此时S的类型可能是IA,也可能是IB。通常我们会在函数参数中使用联合类型,用来将多种类型的变量应用同一个逻辑。当我们想要对其中一种类型添加一个额外的逻辑时,就需要进行类型断言。

但由于TypeScript最终是编译到JavaScript的,所以打根上来说,TypeScript的类型断言其实就是JavaScript的类型判断那一套,只是TypeScript为其赋予了语义。

1function TA(target: IA | IB) {
2    if ((<IA>target).name) {
3        console.log((<IA>target).name)
4    } else {
5        console.log((<IB>target).age)
6    }
7}
8
9TA({ name: "@" })

参数target的类型是IA | IB,我们使用<Type>Variable的语法将其断言为一个确定的类型。注意,这个语法仅能在非tsx环境下使用,tsx环境可以使用Variable as Type的语法。

但在if的block中,我们还得继续使用类型断言才能访问target的属性,这是因为断言只是把变量当成了某种类型,变量本身的类型并没有变化,断言的结果并不能延续到后续作用域中。那么只要使用了联合类型,我们就得带着类型断言过一辈子了吗?答案当然否定的,我们可以使用is进行类型收窄。

 1function isIA(target: IA | IB): target is IA {
 2    if ((<IA>target).name) {
 3        return true;
 4    }
 5    return false
 6}
 7
 8function TA2(target: IA | IB) {
 9    if (isIA(target)) {
10        console.log(target.name)
11    } else {
12        console.log(target.age)
13    }
14}

函数isIA的返回类型是target is IA,指示target的类型是不是IA,你需要返回true或false来明确的告诉它。这里虽然return的是布尔值,但函数本身的返回值并不是布尔值,所以你不能将其应用在使用布尔值的地方,它仅用于类型断言。

在TA2函数中,我们使用isIA(target)来代替原本的类型断言,与TA不同的是,我们在if-else语句中就再也不需要写类型断言了,因为isIA已经明确的告诉了我们target到底是不是IA。

使用in也能进行类型断言,这里的in其实就是JavsScript中的in,只是TypeScript为其赋予了额外的语义。

1function TA3(target: IA | IB) {
2    if ('name' in target) {
3        console.log(target.name)
4    } else {
5        console.log(target.age)
6    }
7}

除了in,typeof、instanceof都能做类型断言,在实际开发中可以选择合适的去使用。

更全面的类型断言

is只能用于if-else在结构中,为分支提供类型断言,在其他地方就不起作用了。如果你只把isIA当作普通函数调用而不是将调用放在if-else条件中,那么isIA调用后的代码也并不会将被检查的代码当作IA。此时我们需要一种能当作普通函数调用的类型断言,在该函数调用后,被检查的类型就会被当作目标类型。这时就可以使用asserts关键字。

1function assert(cond: any): asserts cond { }

这是asserts关键字的基本用法,函数assert的参数cond,它是一个类型断言的条件,返回值为asserts cond,表示我假设这个条件就是真的,之后被断言的变量类型就按照我这个假设来。

1function j(q: string | number) {
2    assert(typeof q === "string");
3
4    q.toLocaleUpperCase(); // q is string
5}

我们假设之后q的类型满足我这个断言条件,也就是说q的类型就是string,在assert之后q的类型就从string | number变成了string。

当然类型断言并不是非得写typeof,只要这个表达式是一个布尔值即可,例如:

1function j(q: string | number) {
2    assert(q === "hello");
3
4    q.toLocaleUpperCase(); // q is string
5}

现在条件变成了q === "hello",也就是说我断言q是字符串"hello",那么q的类型自然也就是string了。

但有一点需要注意,每次调用assert后,被断言的变量的类型会收窄。上面的q最开始是string|number,在被断言成为string后,其类型就变成了单独的string。假设后续跟着断言它是个number,那么q的类型最终就会变成never。

1function j(q: string | number) {
2    assert(typeof q === "string");
3
4    q.toLocaleUpperCase(); // q is string
5
6    assert(typeof q === "number");
7
8    q; // q is never
9}

这时因为每次断言时变量的状态都是根据当前状态计算的,在语义上你先断言这是个string,后断言这是个number,这怎么可能?当然也不是说就一定不能有多个assert了,只要你确保它的类型不收窄到never就好了,例如有多个Union的时候就可以进行多次assert。

1function j(q: string | number | boolean) {
2    assert(typeof q === "string" || typeof q === "number");
3
4    q; // q is string | number
5
6    assert(typeof q === "number");
7
8    q; // q is number
9}

上面的断言总是需要你传一个条件,在某些场景下有些麻烦,我就是想将其断言为某一个值,但不想传条件怎么办?当然是有办法的,就是将asserts与is结合:

1function assert2(val: any): asserts val is number { }
2
3function j(q: string | number | boolean) {
4    assert2(q);
5 
6   q; // q is number
7}

现在的断言函数只需要传被断言的值就行了,当然也可以将其改成泛型,这样就可以快速断言到任意类型了。

1function assert2<T>(val: any): asserts val is T { }

Optional Chaining

终于TypeScript也有Optional Chaining了,这是一个非常好的特性。通常我们写代码的时候都要进行一些保护性编程,例如:

1function foo(s: String | null) {
2    return s && s.toLocaleUpperCase();
3}

如果要保护的层级很深,那就更麻烦了,可能会有一连串的&&。现在有了Optional Chaining,我们可以直接使用?.运算符安全的取值,TypeScript会自动帮我们展开。那么上面的例子就变成了

1function foo(s: String | null) {
2    return s?.toLocaleUpperCase();
3}

Optional Chaining可以一直扩展下去,一旦中间有一步求值失败,后面的就会停止,现在写代码就方便多了。但是Optional Chaining只会对当前链做展开,并不会延展到其他运算中,例如:

1function foo(s: String | null) {
2    return s?.toLocaleUpperCase().length + 2 // 对象可能为“未定义”。
3}

当s?.toLocaleUpperCase求值失败时,s?.toLocaleUpperCase().length会返回undefined,这显然是不能与2进行加法运算的,所以这里会报错。

在某些情况下,如果你确定一个可能是nullable的值一定不是nullable时,你可以使用!.来强制继续运算,例如上面的例子,就可以改成

1function foo(s: String | null) {
2    return s!.toLocaleUpperCase().length + 2
3}

我使用了!.进行强制运算,.length一定是有值的,这样就不会报对象可能为“未定义”这个错误。

要使用Optional Chaining需要将你的TypeScript版本升级至3.7,具体可见3.7版本的[[https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#optional-chaining|release note]]。目前V8也已经原生支持了Optional Chaining,在最新版Chrome中已经可用了,具体可见[[https://v8.dev/features/optional-chaining | v8 blog]]

索引类型

索引类型是TypeScript类型系统中最重要的概念之一,它的核心是帮助你管理对象的键。在JavaScript(TypeScript)中一切皆为对象,而访问对象属性的方式就是通过键,确定了对象的键,就确定了对象的的结构。

在TypeScript中可以通过keyof获取一个对象所有键的联合类型:

1interface IPeople {
2    name: string;
3    age: number;
4    sex: string
5}
6
7type peopleProps = keyof IPeople; // "name" | "age" | "sex"

类型peopleProps就是一个联合类型:"name" | "age" | "sex"。明白了这一点,我们就能用keyof做很多事。

1const obj: IPeople = {
2    name: "foo",
3    age: 1,
4    sex: "male"
5}
6
7function get(key: keyof IPeople) {
8    return obj[key]
9}

这是最简单的例子,函数h的参数key是IPeople键的联合类型,确保了obj一定能被正确访问。使用keyof我们就不必再去手动维护key的类型了,一切交给TypeScript即可。

1function gets<T, K extends keyof T>(obj: T, keys: K[]): T[K][] {
2    return keys.map(item => obj[item]);
3}
4
5console.log(gets(obj, ["age", "name"])); // [ 1, 'foo' ]

这是稍复杂一点的例子,我们使用K extends keyof T来定义泛型K继承与T,gets函数第一个参数是要被提取状态的对象,第二个参数是键名组成的列表,返回值是keys对应键值组成的列表。使用索引类型我们可以方便的维护函数参数类型,当对象键改变时,所有依赖该对象键名的地方会自动修改,极大的提高了效率。

当然这里不使用泛型K也是可以的,就是写法相对有些啰嗦。

1function gets<T>(obj: T, keys: (keyof T)[]): T[(keyof T)][] {
2    return keys.map(item => obj[item]);
3}

看上去有点乱,还是加上泛型K的写法比较清晰。

索引类型的扩展

基于索引类型,我们可以扩展出多种用法。TypeScript内置了多个工具类型,依靠索引类型提供了强大的功能:

 1interface IC {
 2    name: String
 3}
 4
 5type readOnlyIC = Readonly<IC>;
 6
 7const objC: readOnlyIC = {
 8    name: "s"
 9}
10
11objC.name = "12"; // Cannot assign to 'name' because it is a read-only property.ts(2540)

Readonly用于将类型的所有键变为只读。

1type Partial<T> = {
2    [P in keyof T]?: T[P];
3};
4
5type partialIC = Partial<IC>;
6
7const objC2: partialIC = {};

Partial将类型的所有键变为可选。

还有很多就不一一列举了,详细的文档在[[https://www.typescriptlang.org/docs/handbook/utility-types.html]]。

条件类型与infer

TypeScript 2.8中加入了条件类型,从此类型系统不再是一成不变的了,在类型上添加条件判断也是可以的了。条件类型的写法是:

1T extends U ? X : Y

他的意思是如果类型T能够分配给类型U(或者理解为类型U可被类型T表示),那么类型是X,否则是Y。举个简单的例子:

 1function ss<T extends number | string>(x: T): T extends number ? string : number {
 2    if (typeof x === "string") {
 3        return <any>parseInt(x, 10);
 4    } else {
 5        return <any>x.toString();
 6    }
 7}
 8
 9const s1 = ss(1); // s1 type is string
10const s2 = ss("1"); // s1 type is number
11
12console.log(s1); // "1"
13console.log(s2); // 1

函数ss的返回值表明,当参数x的类型是number时,返回值是个string;当参数x的类型是string时,返回值是个number。这里函数体内使用any做类型断言是因为这里的extends使用了泛型参数T,这会导致整个函数的返回值类型计算会被延迟到调用时才能确定,所以在函数体内看,函数的返回值是T extends number ? string : number,并不是确定的string或者number,所以直接return对应的值会与函数签名冲突,解决办法就是使用any做类型断言。

有时我们想根据参数类型确定返回值类型,如果没有条件类型,那么我们返回值只能写成联合类型,但有了条件类型,我们就能立刻根据函数参数类型确定唯一的返回值类型了。

条件类型更常见的用法是用于定义工具类型,TypeScript中就内置了几个依靠条件类型的内置类型,例如:

1type NonNullable<T> = T extends null | undefined ? never : T;
2
3type s = NonNullable<string | null>

NonNullable会过滤掉泛型参数中的null或者undefined。

条件类型另一种用法是与关键字infer结合,这让TypeScript的类型推断更上了一层楼。

我们在日常开发中可能遇到这种场景:函数A的参数类型决定了函数B的返回值,或者函数B的返回值决定了函数A的参数类型。在以往场景中,我们只能手动维护这两种关系。但有了infer,我们就能自动根据一个函数的返回值推断其类型并将其应用在某个地方,或者反过来,那么它是怎么实现的呢?

1type PT<T> = T extends (param: infer P) => any ? P : T;
2type qq = PT<(a: number) => void>; // qq type is number

类型PT中的infer P表示该位置待推断的类型,整个PT表示如果T能被分配给(param) => any这个类型,那么其类型就是待推断的类型P,否则是T。

那么在第二行中,qq的类型显然就是number了。

通过infer我们能够获取任意位置的类型,infer的典型用处就是获取函数参数及返回值类型。这在实际开发中非常有用,因为我们希望只定义一处类型,其他使用的地方都通过类型推断而来,这样工程的类型系统就有很高的可维护性。如果类型总是东定义一次,西定义一次,那么没多久整个项目就会充斥着any。

方法重载

在开发过程中,我们可能遇到同一个函数有的某个参数有多种类型,根据参数类型的不同返回值类型也不同,例如这样:

1function ba(tagName: "div" | "p", content: string): HTMLDivElement | HTMLParagraphElement {
2    const ele = document.createElement(tagName)
3    ele.innerText = content;
4    return ele;
5}
6
7const ea = ba("div", "foo"); // HTMLDivElement | HTMLParagraphElement
8const eb = ba("p", "foo"); // HTMLDivElement | HTMLParagraphElement

函数ba在可能的返回值有两个,根据参数类型不同而不同。但这样写有一个问题,函数的返回值类型总是HTMLDivElement或者HTMLParagraphElement,使用返回值时总是免不了进行类型断言。那么有没有更好的办法让返回类型只有确定的一个呢?那就是使用方法重载。

 1function ba(tagName: "div", content: string): HTMLDivElement;
 2function ba(tagName: "p", content: string): HTMLParagraphElement;
 3function ba(tagName: string, content: string): HTMLElement {
 4    const ele = document.createElement(tagName)
 5    ele.innerText = content;
 6    return ele;
 7}
 8
 9const ea = ba("div", "foo"); // HTMLDivElement
10const eb = ba("p", "foo"); // HTMLParagraphElement

方法重载在函数返回值随参数类型变动而变动时发挥了巨大的作用,现在ea与eb就是确定的HTMLParagraphElement或HTMLDivElement了,不再是二者组成的联合类型。

但方法重载也不是支持任意类型重载的,例如在这里你不能将tagName类型设置为number,他只能是string中一个确定的值。函数定义中的参数与返回值类型必须是基类,方法重载中参数与返回值类型必须是函数定义中的基类或者子类,也就是说方法重载是协变的。