TypeScript学习笔记(2)

365 阅读23分钟

10、泛型

什么是泛型?

借用 Java 中泛型的释义来回答这个问题:泛型指的是类型参数化,即将原来某种具体的类型进行参数化。和定义函数参数一样,我们可以给泛型定义若干个类型参数,并在调用时给泛型传入明确的类型参数。设计泛型的目的在于有效约束类型成员之间的关系,比如函数参数和返回值、类或者接口成员和方法之间的关系。

泛型类型参数

泛型最常用的场景是用来约束函数参数的类型,我们可以给函数定义若干个被调用时才会传入明确类型的参数。

比如以下定义的一个 reflect 函数 ,它可以接收一个任意类型的参数,并原封不动地返回参数的值和类型:

function reflect(param: unknown) {
  return param;
}

const str = reflect('string'); // str 类型是 unknown
const num = reflect(1); // num 类型 unknown

reflect 函数虽然可以接收一个任意类型的参数并原封不动地返回参数的值,不过返回值类型不符合我们的预期(始终是unknown)。

使用泛型可以很好地解决这个问题:

function reflect<P>(param: P) {
  return param;
}

const reflectStr = reflect<string>('string'); // str 类型是 string
const reflectNum = reflect<number>(1); // num 类型 number
// 泛型参数的入参可以从参数的类型中进行推断,所以可缺省
const reflectStr2 = reflect('string'); // str 类型是 string
const reflectNum2 = reflect(1); // num 类型 number

也可以给函数定义任何个数的泛型入参,如下代码所示:

function reflectExtraParams<P, Q>(p1: P, p2: Q): [P, Q] {
  return [p1, p2];
}

泛型类

在类的定义中,我们还可以使用泛型用来约束构造函数、属性、方法的类型,如下代码所示:

class Memory<S> {
  store: S;
  constructor(store: S) {
    this.store = store;
  }

  set(store: S) {
    this.store = store;
  }

  get() {
    return this.store;
  }
}

const numMemory = new Memory<number>(1); // <number> 可缺省

const getNumMemory = numMemory.get(); // 类型是 number

numMemory.set(2); // 只能写入 number 类型

const strMemory = new Memory(''); // 缺省 <string>

const getStrMemory = strMemory.get(); // 类型是 string

strMemory.set('string'); // 只能写入 string 类型

泛型类和泛型函数类似的地方在于,在创建类实例时,如果受泛型约束的参数传入了明确值,则泛型入参(确切地说是传入的类型)可缺省,比如上面,泛型入参就是可以缺省的。

泛型类型

将类型入参的定义移动到类型别名接口名称后,此时定义的一个接收具体类型入参后返回一个新类型的类型就是泛型类型。

下面示例定义了两个可以接收入参 P 的泛型类型:

type GenericReflectFunction<P> = (param: P) => P; // 类型别名

interface IGenericReflectFunction<P> { // 接口
  (param: P): P;
}

const reflectFn4: GenericReflectFunction<string> = reflect; // 具象化泛型

const reflectFn5: IGenericReflectFunction<number> = reflect; // 具象化泛型

const reflectFn3Return = reflectFn4('string'); // 入参和返回值都必须是 string 类型

const reflectFn4Return = reflectFn5(1); //  入参和返回值都必须是 number 类型

在泛型定义中,我们甚至可以使用一些类型操作符进行运算表达,使得泛型可以根据入参的类型衍生出各异的类型,如下代码所示:

type StringOrNumberArray<E> = E extends string | number ? E[] : E;

type StringArray = StringOrNumberArray<string>; // 类型是 string[]

type NumberArray = StringOrNumberArray<number>; // 类型是 number[]

type NeverGot = StringOrNumberArray<boolean>; // 类型是 boolean

如果传入的是string | boolean,则:

type BooleanOrString = string | boolean;

type WhatIsThis = StringOrNumberArray<BooleanOrString>; // 好像应该是 string | boolean ?

type BooleanOrStringGot = BooleanOrString extends string | number ? BooleanOrString[] : BooleanOrString; //  string | boolean

这是所谓的分配条件类型(Distributive Conditional Types):在条件类型判断的情况下(比如上边示例中出现的 extends),如果入参是联合类型,则会被拆解成一个个独立的(原子)类型(成员)进行类型运算。

只有泛型 + extends 三元,才会触发分配条件类型。

注意:枚举类型不支持泛型。

泛型约束

我们可以把泛型入参限定在一个相对更明确的集合内,以便对入参进行约束。

比如最前边提到的原封不动返回参数的 reflect 函数,我们希望把接收参数的类型限定在几种原始类型的集合中,此时就可以使用“泛型入参名 extends 类型”语法达到这个目的,如下代码所示:

function reflectSpecified<P extends number | string | boolean>(param: P):P {
  return param;
}

reflectSpecified('string'); // ok
reflectSpecified(1); // ok
reflectSpecified(true); // ok
reflectSpecified(null); // ts(2345) 'null' 不能赋予类型 'number | string | boolean'

同样,也可以把接口泛型入参约束在特定的范围内,如下代码所示:

// ReduxModelSpecified 泛型仅接收 { id: number; name: string } 接口类型的子类型作为入参
interface ReduxModelSpecified<State extends { id: number; name: string }> {
  state: State
}

type ComputedReduxModel1 = ReduxModelSpecified<{ id: number; name: string; }>; // ok
type ComputedReduxModel2 = ReduxModelSpecified<{ id: number; name: string; age: number; }>; // ok
type ComputedReduxModel3 = ReduxModelSpecified<{ id: string; name: number; }>; // ts(2344)
type ComputedReduxModel4 = ReduxModelSpecified<{ id: number;}>; // ts(2344)

我们还可以在多个不同的泛型入参之间设置约束关系,如下代码所示:

interface ObjSetter {
  <O extends {}, K extends keyof O, V extends O[K]>(obj: O, key: K, value: V): V; 
}

const setValueOfObj: ObjSetter = (obj, key, value) => (obj[key] = value);

setValueOfObj({ id: 1, name: 'name' }, 'id', 2); // ok
setValueOfObj({ id: 1, name: 'name' }, 'name', 'new name'); // ok
setValueOfObj({ id: 1, name: 'name' }, 'age', 2); // ts(2345)
setValueOfObj({ id: 1, name: 'name' }, 'id', '2'); // ts(2345)

它拥有 3 个泛型入参:第 1 个是对象,第 2 个是第 1 个入参属性名集合的子集,第 3 个是指定属性类型的子类型(这里使用了 keyof 操作符)。

泛型入参与函数入参还有一个相似的地方在于,它也可以给泛型入参指定默认值(默认类型),且语法和指定函数默认参数完全一致,如下代码所示:

interface ReduxModelSpecified2<State = { id: number; name: string }> {
  state: State
}

type ComputedReduxModel5 = ReduxModelSpecified2; // ok
type ComputedReduxModel6 = ReduxModelSpecified2<{ id: number; name: string; }>; // ok
type ComputedReduxModel7 = ReduxModelSpecified; // ts(2314) 缺少一个类型参数

泛型入参的约束与默认值还可以组合使用,如下代码所示:

interface ReduxModelMixed<State extends {} = { id: number; name: string }> {
  state: State
}

这里我们限定了泛型 ReduxModelMixed 入参 State 必须是 {} 类型的子类型,同时也指定了入参缺省时的默认类型是接口类型 { id: number; name: string; }

注意{}和object的区别:{} 表示所有原始类型和非原始类型的集合,object 表示所有非原始类型的集合。

11、 类型守卫

类型守卫

TypeScript 中,因为受静态类型检测约束,所以在编码阶段我们必须使用类似的手段确保当前的数据类型支持相应的操作。当然,前提条件是已经显式地注解了类型的多态。

{
  const convertToUpperCase = (strOrArray: string | string[]) => {
    if (typeof strOrArray === 'string') {
      return strOrArray.toUpperCase();
    } else if (Array.isArray(strOrArray)) {
      return strOrArray.map(item => item.toUpperCase());
    }
  }
}

这里的 typeofArray.isArray 条件判断就是类型守卫。

类型守卫的作用在于触发类型缩小。实际上,它还可以用来区分类型集合中的不同成员。

区分联合类型

switch

使用 switch 类型守卫来处理联合类型中成员或者成员属性可枚举的场景,即字面量值的集合,如以下示例:

{
  const convert = (c: 'a' | 1) => {
    switch (c) {
      case 1:
        return c.toFixed(); // c is 1
      case 'a':
        return c.toLowerCase(); // c is 'a'
    }
  }

  const feat = (c: { animal: 'panda'; name: 'China' } | { feat: 'video'; name: 'Japan' }) => {
    switch (c.name) {
      case 'China':
        return c.animal; // c is "{ animal: 'panda'; name: 'China' }"
      case 'Japan':
        return c.feat; // c is "{ feat: 'video'; name: 'Japan' }"
    }
  };
}

字面量恒等

switch 适用的场景往往也可以直接使用字面量恒等比较进行替换,比如前边的 convert 函数可以改造成以下示例:

  const convert = (c: 'a' | 1) => {
    if (c === 1) {
      return c.toFixed(); // c is 1
    } else if (c === 'a') {
        return c.toLowerCase(); // c is 'a'
    }
  }

建议:一般来说,如果可枚举的值和条件分支越多,那么使用 switch 就会让代码逻辑更简洁、更清晰;反之,则推荐使用字面量恒等进行判断。

typeof

当联合类型的成员不可枚举,比如说是字符串、数字等原子类型组成的集合,这个时候就需要使用 typeof

  const convert = (c: 'a' | 1) => {
    if (typeof c === 'number') {
      return c.toFixed(); // c is 1
    } else if (typeof c === 'string') {
        return c.toLowerCase(); // c is 'a'
    }
  }

typeof XXX 表达式的返回值类型是字面量联合类型 'string' | 'number' | 'bigint' | 'boolean' | 'symbol' | 'undefined' | 'object' | 'function'

instanceof

判断类的类型:

{
  class Dog {
    wang = 'wangwang';
  }

  class Cat {
    miao = 'miaomiao';
  }

  const getName = (animal: Dog | Cat) => {
    if (animal instanceof Dog) {
      return animal.wang;
    } else if (animal instanceof Cat) {
      return animal.miao;
    }
  }
}

in

当联合类型的成员包含接口类型(对象),并且接口之间的属性不同,如下示例中的接口类型 Dog、Cat,我们不能直接通过“ . ”操作符获取 paramwangmiao 属性,从而区分它是 Dog 还是 Cat

{
  interface Dog {
    wang: string;
  }

  interface Cat {
    miao: string;
  }

  const getName = (animal: Dog | Cat) => {
    if (typeof animal.wang == 'string') { // ts(2339)
      return animal.wang; // ts(2339)
    } else if (animal.miao) { // ts(2339)
      return animal.miao; // ts(2339)
    }
  }
}

  const getName = (animal: Dog | Cat) => {
    if ('wang' in animal) { // ok
      return animal.wang; // ok
    } else if ('miao' in animal) { // ok
      return animal.miao; // ok
    }
  }

自定义类型守卫

使用类型谓词 is,比如封装一个 isDog 函数来区分 Dog 和 Cat,如下代码所示:

  const isDog = function (animal: Dog | Cat): animal is Dog {
    return 'wang' in animal;
  }

  const getName = (animal: Dog | Cat) => {
    if (isDog(animal)) {
      return animal.wang;
    }
  }

区分枚举类型

直接看代码:

{

  enum A {
    one,
    two
  }

  enum B {
    one,
    two
  }

  const cpWithNumber = (param: A) => {
    if (param === 1) { // bad
      return param;
    }
  }

  const cpWithOtherEnum = (param: A) => {
    if (param === B.two as unknown as A) { // ALERT bad,这里使用了【双重类型断言】
      return param;
    }
  }

  const cpWithSelf = (param: A) => {
    if (param === A.two) { // good
      return param;
    }
  }
}

最佳实践是区分枚举成员的判断方式。

双重类型断言: 如果A 不能直接断言成 B,就需要双重断言。

失效的类型守卫

失效的类型守卫指的是某些类型守卫应用在泛型函数中时不能缩小类型,即失效了。比如我们改造了一个可以接受泛型入参的 getName 函数,如下代码所示:

const getName = <T extends Dog | Cat>(animal: T) => {
  if ('wang' in animal) {
    return animal.wang; // ts(2339)
  }
  return animal.miao; // ts(2339)
};

在上述示例中,虽然我们在第 2 行使用了 in 类型守卫,但是它并没有让 animal 的类型如预期那样缩小。

但是,我们把 in 操作换成自定义类型守卫 isDog 或者使用 instanceOf,animal 的类型就会缩小成了 Dog 的子类型(T & Dog)

const getName = <T extends Dog | Cat>(animal: T) => {
  if (isDog(animal)) { // instanceOf 亦可
    return animal.wang; // ok
  }

  return animal.miao; // ts(2339)
};

但是,在缺省的 else 条件分支里,animal 的类型并没有缩小成 Cat 的子类型,所以第 5 行依旧会提示一个 ts(2339) 的错误(这是一个不太科学的设计,所幸在 TypeScript 4.3.2 里已经修改了)。

const getName = <T extends Dog | Cat>(animal: T) => {
  if (isDog(animal)) { // instanceOf 亦可
    return animal.wang; // ok
  }

  return (animal as Cat).miao; // ts(2339)
};

12、类型兼容

特例

any

any 类型可以赋值给除了 never 之外的任意其他类型,反过来其他类型也可以赋值给 any。也就是说 any 可以兼容除 never 之外所有的类型,同时也可以被所有的类型兼容(即 any 既是 bottom type,也是 top type)。

Any is Hell,我们一定要慎用、少用。

never

因为never是所有类型的子类型,所以可以赋值给任何其他类型,但反过来不能被其他任何类型(包括 any 在内)赋值(即 neverbottom type)。

unknown

unknown 的特性和 never 的特性几乎反过来,即我们不能把 unknown 赋值给除了 any 之外任何其他类型,反过来其他类型都可以赋值给 unknown(即 unknowntop type

void、null、undefined

void 类型仅可以赋值给 anyunknown 类型(下面示例第 9~10 行),反过来仅 anyneverundefined 可以赋值给 void

{

  let thisIsAny: any;
  let thisIsNever: never;
  let thisIsUnknown: unknown;
  let thisIsVoid: void;
  let thisIsUndefined: undefined;
  let thisIsNull: null;
  thisIsAny = thisIsVoid; // ok
  thisIsUnknown = thisIsVoid; // ok
  thisIsVoid = thisIsAny; // ok
  thisIsVoid = thisIsNever; // ok
  thisIsVoid = thisIsUndefined; // ok
  thisIsAny = thisIsNull; // ok
  thisIsUnknown = thisIsNull; // ok
  thisIsAny = thisIsUndefined; // ok
  thisIsUnknown = thisIsUndefined; // ok

  thisIsNull = thisIsAny; // ok
  thisIsNull = thisIsNever; // ok
  thisIsUndefined = thisIsAny; // ok
  thisIsUndefined = thisIsNever; // ok
}

在我们推崇并使用的严格模式下,nullundefined 表现出与 void 类似的兼容性,即不能赋值给除 anyunknown 之外的其他类型(上面示例第 1518 行),反过来其他类型(除了 any 和 never 之外)都不可以赋值给 nullundefined(上面示例第 2023 行)。

enum

数字枚举和数字类型相互兼容:

{
  enum A {
    one
  }

  let num: number = A.one; // ok
  let fun = (param: A) => void 0;
  fun(1); // ok 
}

不同枚举之间不兼容。:

{
  enum A {
    one
  }

  enum B {
    one
  }

  let a: A;
  let b: B;
  a = b; // ts(2322)
  b = a; // ts(2322)
}

类型兼容性

子类型

所有的子类型与它的父类型都兼容。由子类型组成的联合类型也可以兼容它们父类型组成的联合类型。

结构类型

如果两个类型的结构一致,则它们是互相兼容的。比如拥有相同类型的属性、方法的接口类型或类,则可以互相赋值。

如果两个接口类型或者类,如果其中一个类型不仅拥有另外一个类型全部的属性和方法,还包含其他的属性和方法(如同继承自另外一个类型的子类一样),那么前者是可以兼容后者的。

{
  interface I1 {
    name: string;
  }

  interface I2 {
    id: number;
    name: string;
  }

  class C2 {
    id = 1;
    name = '1';
  }

  let O1: I1;
  let O2: I2;
  let InstC2: C2;
  O1 = O2;
  O1 = InstC2;
}

注意:虽然包含多余属性 id 的变量 O2 可以赋值给变量 O1,但是如果我们直接将一个与变量 O2 完全一样结构的对象字面量赋值给变量 O1,则会提示一个 ts(2322) 类型不兼容的错误(如下示例第 2 行),这就是对象字面的 freshness 特性。

也就是说一个对象字面量没有被变量接收时,它将处于一种 freshness 新鲜的状态。这时 TypeScript 会对对象字面量的赋值操作进行严格的类型检测,只有目标变量的类型与对象字面量的类型完全一致时,对象字面量才可以赋值给目标变量,否则会提示类型错误。

当然,我们也可以通过使用变量接收对象字面量使用类型断言解除 freshness,如下示例:

  O1 = {
    id: 2, // ts(2322)
    name: 'name'
  };

  let O3 = {
    id: 2,
    name: 'name'
  };

  O1 = O3; // ok 变量接收对象字面量
  O1 = {
    id: 2,
    name: 'name'
  } as I2; // ok 类型断言

我们还需要注意类兼容性特性:实际上,在判断两个类是否兼容时,我们可以完全忽略其构造函数及静态属性和方法是否兼容,只需要比较类实例的属性和方法是否兼容即可。如果两个类包含私有、受保护的属性和方法,则仅当这些属性和方法源自同一个类,它们才兼容。

可继承和可实现

类型兼容性还决定了接口类型和类是否可以通过 extends 继承另外一个接口类型或者类,以及类是否可以通过 implements 实现接口。

{
  interface I1 {
    name: number;
  }

  interface I2 extends I1 { // ts(2430)
    name: string;
  }

  class C1 {
    name = '1';
    private id = 1;
  }

  class C2 extends C1 { // ts(2415)
    name = '2';
    private id = 1;
  }

  class C3 implements I1 {
    name = ''; // ts(2416)
  }

}

泛型

泛型类型、泛型类的兼容性实际指的是将它们实例化为一个确切的类型后的兼容性。

变型

TypeScript 中的变型指的是根据类型之间的子类型关系推断基于它们构造的更复杂类型之间的子类型关系。比如根据 Dog 类型是 Animal 类型子类型这样的关系,我们可以推断数组类型 Dog[]Animal[] 、函数类型 () => Dog() => Animal 之间的子类型关系。

在描述类型和基于类型构造的复杂类型之间的关系时,我们可以使用数学中函数的表达方式。比如 Dog 类型,我们可以使用 F(Dog) 表示构造的复杂类型;F(Animal) 表示基于 Animal 构造的复杂类型。

协变

协变也就是说如果 DogAnimal 的子类型,则 F(Dog)F(Animal) 的子类型,这意味着在构造的复杂类型中保持了一致的子类型关系。

接口类型的属性、数组类型、函数返回值的类型都是协变的

逆变

逆变也就是说如果 DogAnimal 的子类型,则 F(Dog)F(Animal) 的父类型,这与协变正好反过来。

实际场景中,在我们推崇的 TypeScript 严格模式下,函数参数类型是逆变的

  const visitDog = (animal: Dog) => {
    animal.woof();
  };
  let animals: Animal[] = [{ name: 'Cat', miao: () => void 0, }];
  animals.forEach(visitDog); // ts(2345)

在示例中,如果函数参数类型是协变的,那么第 5 行就可以通过静态类型检测,而不会提示一个 ts(2345) 类型的错误。这样第 1 行定义的 visitDog 函数在运行时就能接收到 Dog 类型之外的入参,并调用不存在的 woof 方法,从而在运行时抛出错误。

正是因为函数参数是逆变的,所以使用 visitDog 函数遍历 Animal[] 类型数组时,在第 5 行提示了类型错误,因此也就不出现 visitDog 接收到一只 cat 的情况。

双向协变

TypeScript 非严格模式下,函数参数类型就是双向协变的。但是双向协变并不是一个安全或者有用的特性,因此我们不大可能遇到这样的实际场景。

  interface Event {
    timestamp: number;
  }

  interface MouseEvent extends Event {
    x: number;
    y: number;
  }

  // bad
  function addEventListener(handler: (n: Event) => void) {}
  addEventListener((e: MouseEvent) => console.log(e.x + ',' + e.y)); // 严格模式在会ts(2769)
  
  // good 使用泛型
  function addEventListener<E extends Event>(handler: (n: E) => void) {}
  addEventListener((e: MouseEvent) => console.log(e.x + ',' + e.y)); // ok

不变

不变即只要是不完全一样的类型,它们一定是不兼容的。也就是说即便 DogAnimal 的子类型,如果 F(Dog) 不是 F(Animal) 的子类型,那么 F(Animal) 也不是 F(Dog) 的子类型。

对于可变的数组而言,不变似乎是更安全、合理的设定。不过,在 TypeScript 中可变、不变的数组都是协变的,这是需要我们注意的一个陷阱。

函数类型兼容性

返回值

返回值类型是协变的,所以在参数类型兼容的情况下,函数的子类型关系与返回值子类型关系一致。也就是说返回值类型兼容,则函数兼容。

参数类型

参数类型是逆变的,所以在参数个数相同、返回值类型兼容的情况下,函数子类型关系与参数子类型关系是反过来的(逆变)。

参数个数

在索引位置相同的参数和返回值类型兼容的前提下,函数兼容性取决于参数个数,参数个数少的兼容个数多。

可选和剩余参数

可选参数可以兼容剩余参数、不可选参数,下面我们具体看一个示例:

  let optionalParams = (one?: number, tow?: number) => void 0;
  let requiredParams = (one: number, tow: number) => void 0;
  let restParams = (...args: number[]) => void 0;

  requiredParams = optionalParams; // ok
  restParams = optionalParams; // ok
  optionalParams = restParams; // ts(2322)
  optionalParams = requiredParams; // ts(2322)
  restParams = requiredParams; // ok
  requiredParams = restParams; // ok

不可选参数和剩余参数是互相兼容的;剩余参数函数可以兼容任意个数参数函数,这是安全的;不可选参数是剩余参数函数((...args: any[]) => any)子类型,是不安全但是便捷的,所以不可选兼容剩余。

13、增强类型系统

TypeScript 增强类型系统可以解决下面的问题:某些库没有提供类型声明、库的版本和类型声明不一致、没有注入全局变量类型等。

增强类型系统

顾名思义就是对 TypeScript 类型系统的增强。

TypeScript 相较于 JavaScript 而言,其一大特点就是类型。关于类型的定义方法,除了之前学习的内容之外,我们还可以通过以下方式扩展类型系统。

声明

通过使用 declare 关键字,我们可以声明全局的变量、方法、类、对象。

declare 变量

语法: declare (var|let|const) 变量名称: 变量类型

declare var val1: string;
declare let val2: number;
declare const val3: boolean;

val1 = '1';
val1 = '2';
val2 = 1;
val2 = '2'; // TS2322: Type 'string' is not assignable to type 'number'.
val3 = true; // TS2588: Cannot assign to 'val3' because it is a constant.

声明函数

声明函数的语法与声明变量类型的语法相同,不同的是 declare 关键字后需要跟 function 关键字,如下示例:

declare function toString(x: number): string;
const x = toString(1); // => string

注意:使用 declare关键字时,我们不需要编写声明的变量、函数、类的具体实现(因为变量、函数、类在其他库中已经实现了),只需要声明其类型即可

声明类

声明类时,我们只需要声明类的属性、方法的类型即可。另外,关于类的可见性修饰符我们也可以在此进行声明,下面看一个具体的示例:

declare class Person {
  public name: string;
  private age: number;
  constructor(name: string);
  getAge(): number;
}

const person = new Person('Mike');
person.name; // => string
person.age; // TS2341: Property 'age' is private and only accessible within class 'Person'.
person.getAge(); // => number

声明枚举

声明枚举只需要定义枚举的类型,并不需要定义枚举的值,如下示例:

declare enum Direction {
  Up,
  Down,
  Left,
  Right,
}

const directions = [Direction.Up, Direction.Down, Direction.Left, Direction.Right];

注意:声明枚举仅用于编译时的检查,编译完成后,声明文件中的内容在编译结果中会被删除, 相当于仅剩下面使用的语句:

const directions = [Direction.Up, Direction.Down, Direction.Left, Direction.Right];

这里的 Direction 表示引入的全局变量。

除了声明变量、函数、类型、枚举之外,我们还可以使用 declare 增强文件、模块的类型系统。

declare 模块

TypeScript 使用 namespace 替代了原来的模块 module,并更名为命名空间(避免与ES6的module冲突)。

TypeScriptES6 一样,任何包含顶级 importexport 的文件都会被当作一个模块。我们可以通过声明模块类型,为缺少 TypeScript 类型定义的三方库或者文件补齐类型定义,如下示例:

// lodash.d.ts
declare module 'lodash' {
  export function first<T extends unknown>(array: T[]): T;
}

// index.ts
import { first } from 'lodash';
first([1, 2, 3]); // => number;

在上面的例子中,lodash.d.ts 声明了模块 lodash 导出的 first 方法,然后在 TypeScript 文件中使用了模块 lodash 中的 first 方法。

声明模块的语法: declare module '模块名' {}

在模块声明的内部,我们只需要使用 export 导出对应库的类、函数即可。

declare 文件

declare namespace

不同于声明模块,命名空间一般用来表示具有很多子属性或者方法的全局对象变量。

我们可以将声明命名空间简单看作是声明一个更复杂的变量,如下示例:

declare namespace $ {
  const version: number;
  function ajax(settings?: any): void;
}

$.version; // => number
$.ajax();

声明文件

TypeScript 中,我们还可以编写以 .d.ts 为后缀的声明文件来增强(补齐)类型系统。在 TypeScript 中,以 .d.ts 为后缀的文件为声明文件(类似C/C++中的.h文件)。在声明文件时,我们只需要定义三方类库所暴露的 API 接口即可。

TypeScript 中,存在类型、值、命名空间这 3 个核心概念。如果掌握了这些核心概念,那么就能够为任何形式的类型书写声明文件了。

类型

  • 类型别名声明;
  • 接口声明;
  • 类声明;
  • 枚举声明;
  • 导入的类型声明。

上面的每一个声明都创建了一个类型名称。

值就是在运行时表达式可以赋予的值。

我们可以通过以下 6 种方式创建值:

  • var、let、const 声明;
  • namespace、module 包含值的声明;
  • 枚举声明;
  • 类声明;
  • 导入的值;
  • 函数声明。

命名空间

在命名空间中,我们也可以声明类型。比如 const x: A.B.C 这个声明,这里的类型 C 就是在 A.B 命名空间下的。

使用声明文件

安装 TypeScript 依赖后,一般我们会顺带安装一个 lib.d.ts 声明文件,这个文件包含了 JavaScript 运行时以及 DOM 中各种全局变量的声明,如下示例:

// typescript/lib/lib.d.ts
/// <reference no-default-lib="true"/>
/// <reference lib="es5" />
/// <reference lib="dom" />
/// <reference lib="webworker.importscripts" />
/// <reference lib="scripthost" />

其中,/// 是 TypeScript 中**三斜线指令**,后面的内容类似于 XML 标签的语法,用来指代引用其他的声明文件。通过三斜线指令,我们可以更好地复用和拆分类型声明。no-default-lib="true" 表示这个文件是一个默认库。而最后 4 行的lib="..." 表示引用内部的库类型声明。

使用 @types

Definitely Typed是最流行性的高质量 TypeScript 声明文件类库,正是因为有社区维护的这个声明文件类库,大大简化了 JavaScript 项目迁移 TypeScript 的难度。

目前,社区已经记录了 90% 的 JavaScript 库的类型声明,意味着如果我们想使用的库有社区维护的类型声明,那么就可以通过安装类型声明文件直接使用 JavaScript 编写的类库了。

具体操作:首先,点击这里的链接搜索你想要导入的类库的类型声明,如果有社区维护的声明文件。然后,我们只需要安装 @types/xxx 就可以在 TypeScript 中直接使用它了。

然而,因为 Definitely Typed 是由社区人员维护的,如果原来的三方库升级,那么 Definitely Typed 所导出的三方库的类型定义想要升级还需要经过 PR、发布的流程,就会导致无法与原库保持完全同步。针对这个问题,在 TypeScript 中,我们可以通过类型合并、扩充类型定义的技巧临时解决。

类型合并

TypeScript 中,相同的接口、命名空间会依据一定的规则进行合并。

合并接口

interface Person {
  name: string;
}

interface Person {
  age: number;
}

// 相当于
interface Person {
  name: string;
  age: number;
}

注意:接口的非函数成员类型必须完全一样,如下示例:

interface Person {
  age: string;
}

interface Person {
  // TS2717: Subsequent property declarations must have the same type.
  // Property 'age' must be of type 'string', but here has type 'number'.
  age: number;
}

对于函数成员而言,每个同名的函数声明都会被当作这个函数的重载。注意后面声明的接口具有更高的优先级

合并 namespace

合并 namespace 与合并接口类似,命名空间的合并也会合并其导出成员的属性。不同的是,非导出成员仅在原命名空间内可见

namespace Person {
  const age = 18;
  export function getAge() {
    return age;
  }
}

namespace Person {
  export function getMyAge() {
    return age; // TS2304: Cannot find name 'age'.
  }
}

不可合并

定义一个类类型,相当于定义了一个类,又定义了一个类的类型。因此,对于类这个既是值又是类型的特殊对象不能合并。

扩充模块

JavaScript 是一门动态类型的语言,通过 prototype 我们可以很容易地扩展原来的对象。

但是,如果我们直接扩展导入对象的原型链属性,TypeScript 会提示没有该属性的错误,因此我们就需要扩展原模块的属性。

// person.ts
export class Person {}

// index.ts
import { Person } from './person';

declare module './person' {
  interface Person {
    greet: () => void;
  }
}

Person.prototype.greet = () => {
  console.log('Hi!');
};

在上面的例子中,我们声明了导入模块 person 中 Person 的属性,TypeScript 会与原模块的类型合并,通过这种方式我们可以扩展导入模块的类型。同时,我们为导入的 Person 类增加了原型链上的 greet 方法。

// person.ts
export class Person {}

// index.ts
import { Person } from './person';

declare module './person' {
  interface Person {
    greet: () => void;
  }
}

- declare module './person' {
-   interface Person {
-     greet: () => void;
-   }
- }

+ // TS2339: Property 'greet' does not exist on type 'Person'.
Person.prototype.greet = () => {
  console.log('Hi!');
};

如果我们删除了扩展模块的声明,则会报出 ts(2339) 不存在 greet 属性的类型错误。

对于导入的三方模块,我们同样可以使用这个方法扩充原模块的属性。

扩充全局

全局模块指的是不需要通过 import 导入即可使用的模块,如全局的 windowdocument 等。

对全局对象的扩充与对模块的扩充是一样的,下面看一个具体示例:

declare global {
  interface Array<T extends unknown> {
    getLen(): number;
  }
}

Array.prototype.getLen = function () {
  return this.length;
};

在上面的例子中,因为我们声明了全局的 Array 对象有一个 getLen 方法,所以为 Array 对象实现 getLen 方法时,TypeScript 不会报错。