Typescript基础学习总结(三)

590 阅读50分钟

6类类型-高效使用类型化的面向对象编程利器

集面向对象抽象、封装、多态三要素为一体的编程利器,类类型。

在JavaScript(ES5)中仅支持通过函数和原型链继承模拟类的实现(用于抽象业务模型、组织数据结构并创建可重用组件),自 ES6 引入 class 关键字后,它才开始支持使用与Java类似的语法定义声明类。

6.1类

任何实体都可以被抽象为一个使用类表达的类似对象的数据结构,且这个数据结构既包含属性,又包含方法。

如果使用传统的 JavaScript 代码定义类,我们需要使用函数+原型链的形式进行模拟,如下代码所示:

function Dog(name: string) {
  this.name = name; // ts(2683) 'this' implicitly has type 'any' because it does not have a type annotation.
}
Dog.prototype.bark = function () {
  console.log('Woof! Woof!');
};
​
const dog = new Dog('Q'); // ts(7009) 'new' expression, whose target lacks a construct signature, implicitly has an 'any' type.
dog.bark(); // => 'Woof! Woof!'

和通过 class 方式定义类相比,这种方式明显麻烦不少,而且还缺少静态类型检测。

6.2继承

使用 extends 关键字就能很方便地定义类继承的抽象模式

class Animal {
  type = 'Animal';
  say(name: string) {
    console.log(`I'm ${name}!`);
  }
}

class Dog extends Animal {
  bark() {
    console.log('Woof! Woof!');
  }
}

const dog = new Dog();
dog.bark(); // => 'Woof! Woof!'
dog.say('Q'); // => I'm Q!
dog.type; // => Animal

说明:派生类通常被称作子类,基类也被称作超类(或者父类)。

派生类如果包含一个构造函数,则必须在构造函数中调用 super() 方法,这是 TypeScript 强制执行的一条重要规则。如下:

class Dog extends Animal {
  name: string;
  constructor(name: string) { // ts(2377) Constructors for derived classes must contain a 'super' call.
    this.name = name;
  }

  bark() {
    console.log('Woof! Woof!');
  }
}

class Dog extends Animal {
  name: string;
  constructor(name: string) {
    super(); // 添加 super 方法
    this.name = name;
  }

  bark() {
    console.log('Woof! Woof!');
  }
}

这里的 super 函数会调用基类的构造函数

class Animal {
  weight: number;
  type = 'Animal';
  constructor(weight: number) {
    this.weight = weight;
  }
  say(name: string) {
    console.log(`I'm ${name}!`);
  }
}

class Dog extends Animal {
  name: string;
  constructor(name: string) {
    super(); // ts(2554) Expected 1 arguments, but got 0.
    this.name = name;
  }

  bark() {
    console.log('Woof! Woof!');
  }
}

将鼠标放到第 15 行 Dog 类构造函数调用的 super 函数上,我们可以看到一个提示,它的类型是基类 Animal 的构造函数:constructor Animal(weight: number): Animal 。并且因为 Animal 类的构造函数要求必须传入一个数字类型的 weight 参数,而第 15 行实际入参为空,所以提示了一个 ts(2554) 的错误;如果我们显式地给 super 函数传入一个 number 类型的值,比如说 super(20),则不会再提示错误了。

6.3公共、私有与受保护的修饰符

类属性和方法除了可以通过 extends 被继承之外,还可以通过修饰符控制可访问性

在 TypeScript 中就支持 3 种访问修饰符,分别是 public、private、protected

  • public 修饰的是在任何地方可见、公有的属性或方法;

  • private 修饰的是仅在同一类中可见、私有的属性或方法;

  • protected 修饰的是仅在类自身及子类中可见、受保护的属性或方法。

在之前的代码中,示例类并没有用到可见性修饰符,在缺省情况下,类的属性或方法默认都是 public。如果想让有些属性对外不可见,那么我们可以使用private进行设置,如下所示:

class Son {
  public firstName: string;
  private lastName: string = 'Stark';
  constructor(firstName: string) {
    this.firstName = firstName;
    this.lastName; // ok
  }
}

const son = new Son('Tony');
console.log(son.firstName); //  => "Tony"
son.firstName = 'Jack';
console.log(son.firstName); //  => "Jack"
console.log(son.lastName); // ts(2341) Property 'lastName' is private and only accessible within class 'Son'.

注意:TypeScript 中定义类的私有属性仅仅代表静态类型检测层面的私有。如果我们强制忽略 TypeScript 类型的检查错误,转译且运行 JavaScript 时依旧可以获取到 lastName 属性,这是因为 JavaScript 并不支持真正意义上的私有属性

class Son {
  public firstName: string;
  protected lastName: string = 'Stark';
  constructor(firstName: string) {
    this.firstName = firstName;
    this.lastName; // ok
  }
}

class GrandSon extends Son {
  constructor(firstName: string) {
    super(firstName);
  }

  public getMyLastName() {
    return this.lastName;
  }
}

const grandSon = new GrandSon('Tony');
console.log(grandSon.getMyLastName()); // => "Stark"
grandSon.lastName; // ts(2445) Property 'lastName' is protected and only accessible within class 'Son' and its subclasses.

在第 3 行,修改 Son 类的 lastName 属性可见修饰符为 protected,表明此属性在 Son 类及其子类中可见。如示例第 6 行和第 16 行所示,我们既可以在父类 Son 的构造器中获取 lastName 属性值,又可以在继承自 Son 的子类 GrandSon 的 getMyLastName 方法获取 lastName 属性的值。

需要注意:虽然我们不能通过派生类的实例访问protected修饰的属性和方法,但是可以通过派生类的实例方法进行访问。比如示例中的第 21 行,通过实例的 getMyLastName 方法获取受保护的属性 lastName 是 ok 的,而第 22 行通过实例直接获取受保护的属性 lastName 则提示了一个 ts(2445) 的错误。

6.4只读修饰符

如果我们不希望类的属性被更改,则可以使用 readonly 只读修饰符声明类的属性

class Son {
  public readonly firstName: string;
  constructor(firstName: string) {
    this.firstName = firstName;
  }
}
const son = new Son('Tony');
son.firstName = 'Jack'; // ts(2540) Cannot assign to 'firstName' because it is a read-only property.

注意:如果只读修饰符和可见性修饰符同时出现,我们需要将只读修饰符写在可见修饰符后面。

6.5存取器

在 TypeScript 中还可以通过getter、setter截取对类成员的读写访问

通过对类属性访问的截取,我们可以实现一些特定的访问控制逻辑。

class Son {
  public firstName: string;
  protected lastName: string = 'Stark';
  constructor(firstName: string) {
    this.firstName = firstName;
  }
}
class GrandSon extends Son {
  constructor(firstName: string) {
    super(firstName);
  }
  get myLastName() {
    return this.lastName;
  }
  set myLastName(name: string) {
    if (this.firstName === 'Tony') {
      this.lastName = name;
    } else {
      console.error('Unable to change myLastName');
    }
  }
}
const grandSon = new GrandSon('Tony');
console.log(grandSon.myLastName); // => "Stark"
grandSon.myLastName = 'Rogers';
console.log(grandSon.myLastName); // => "Rogers"
const grandSon1 = new GrandSon('Tony1');
grandSon1.myLastName = 'Rogers'; // => "Unable to change myLastName"

6.6静态属性

以上介绍的关于类的所有属性和方法,只有类在实例化时才会被初始化。实际上,我们也可以给类定义静态属性和方法。

因为这些属性存在于类这个特殊的对象上,而不是类的实例上,所以我们可以直接通过类访问静态属性,如下代码所示:

class MyArray {
  static displayName = 'MyArray';
  static isArray(obj: unknown) {
    return Object.prototype.toString.call(obj).slice(8, -1) === 'Array';
  }
}
console.log(MyArray.displayName); // => "MyArray"
console.log(MyArray.isArray([])); // => true
console.log(MyArray.isArray({})); // => false

通过 static 修饰符,我们给 MyArray 类分别定义了一个静态属性 displayName 和静态方法 isArray。之后,我们无须实例化 MyArray 就可以直接访问类上的静态属性和方法了

基于静态属性的特性,我们往往会把与类相关的常量、不依赖实例 this 上下文的属性和方法定义为静态属性,从而避免数据冗余,进而提升运行性能。

注意:上边我们提到了不依赖实例 this 上下文的方法就可以定义成静态方法,这就意味着需要显式注解 this 类型才可以在静态方法中使用 this;非静态方法则不需要显式注解 this 类型,因为 this 的指向默认是类的实例。

6.7抽象类

它是一种不能被实例化仅能被子类继承的特殊类

我们可以使用抽象类定义派生类需要实现的属性和方法,同时也可以定义其他被继承的默认属性和方法,如下代码所示:

abstract class Adder {
  abstract x: number;
  abstract y: number;
  abstract add(): number;
  displayName = 'Adder';
  addTwice(): number {
    return (this.x + this.y) * 2;
  }
}
class NumAdder extends Adder {
  x: number;
  y: number;
  constructor(x: number, y: number) {
    super();
    this.x = x;
    this.y = y;
  }
  add(): number {
    return this.x + this.y;
  }
}
const numAdder = new NumAdder(1, 2);
console.log(numAdder.displayName); // => "Adder"
console.log(numAdder.add()); // => 3
console.log(numAdder.addTwice()); // => 6

通过 abstract 关键字,我们定义了一个抽象类 Adder,并通过abstract关键字定义了抽象属性x、y及方法add,而且任何继承 Adder 的派生类都需要实现这些抽象属性和方法

如果派生类中缺少对 x、y、add 这三者中任意一个抽象成员的实现,那么第 12 行就会提示一个 ts(2515) 错误,关于这点你可以亲自验证一下。

抽象类中的其他非抽象成员则可以直接通过实例获取,比如第 26~28 行中,通过实例 numAdder,我们获取了 displayName 属性和 addTwice 方法。

因为抽象类不能被实例化,并且派生类必须实现继承自抽象类上的抽象属性和方法定义,所以抽象类的作用其实就是对基础逻辑的封装和抽象。

实际上,我们也可以定义一个描述对象结构的接口类型(详见 07 讲)抽象类的结构,并通过 implements 关键字约束类的实现。

使用接口与使用抽象类相比,区别在于接口只能定义类成员的类型

interface IAdder {
  x: number;
  y: number;
  add: () => number;
}
class NumAdder implements IAdder {
  x: number;
  y: number;
  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }
  add() {
    return this.x + this.y;
  }
  addTwice() {
    return (this.x + this.y) * 2;
  }
}

6.8类的类型

类的最后一个特性——类的类型和函数类似,即在声明类的时候,其实也同时声明了一个特殊的类型(确切地讲是一个接口类型),这个类型的名字就是类名,表示类实例的类型;在定义类的时候,我们声明的除构造函数外所有属性、方法的类型就是这个特殊类型的成员。

class A {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}
const a1: A = {}; // ts(2741) Property 'name' is missing in type '{}' but required in type 'A'.
const a2: A = { name: 'a2' }; // ok

我们在定义类 A ,也说明我们同时定义了一个包含字符串属性 name 的同名接口类型 A。因此,在第 7 行把一个空对象赋值给类型是 A 的变量 a1 时,TypeScript 会提示一个 ts(2741) 错误,因为缺少 name 属性。在第 8 行把对象{ name: 'a2' }赋值给类型同样是 A 的变量 a2 时,TypeScript 就直接通过了类型检查,因为有 name 属性。

在 TypeScript 中,因为我们需要实践 OOP 编程思想,所以离不开类的支撑。在实际工作中,类与函数一样,都是极其有用的抽象、封装利器

7接口类型与类型别名

这一讲我们将学习 TypeScript 与 JavaScript 不一样却堪称精华之一的特性——接口类型与类型别名。这些特性让 TypeScript 具备了 JavaScript 所缺少的、描述较为复杂数据结构的能力。在使用 TypeScript 之前,可能我们只能通过文档或大量的注释来做这件事。

7.1Interface 接口类型

TypeScript 不仅能帮助前端改变思维方式,还能强化面向接口编程的思维和能力,而这正是得益于 Interface 接口类型。通过接口类型,我们可以清晰地定义模块内、跨模块、跨项目代码的通信规则。

TypeScript 对对象的类型检测遵循一种被称之为“鸭子类型”(duck typing)或者“结构化类型(structural subtyping)”的准则,即只要两个对象的结构一致,属性和方法的类型一致,则它们的类型就是一致的。

function Study(language: { name: string; age: () => number }) {
  console.log(`ProgramLanguage ${language.name} created ${language.age()} years ago.`);
}
Study({
  name: 'TypeScript',
  age: () => new Date().getFullYear() - 2012
});

在调用函数的过程中,TypeScript 静态类型检测到传递的对象字面量类型为 string 的 name 属性和类型为() => number 的 age 属性与函数参数定义的类型一致,于是不会抛出一个类型错误。

如果我们传入一个 name 属性是 number 类型或者缺少age属性的对象字面量,

Study({
  name: 2,
  age: () => new Date().getFullYear() - 2012
});
Study({
  name: 'TypeScript'
});

这时,第 2 行会提示错误: ts(2322) number 不能赋值给 string,第 7 行也会提示错误: ts(2345) 实参(Argument)与形参(Parameter)类型不兼容,缺少必需的属性 age。

同样,如果我们传入一个包含了形参类型定义里没有的 id 属性的对象字面量作为实参,也会得到一个类型错误 ts(2345),实参(Argument)与形参(Parameter)类型不兼容,不存在的属性 id,如下代码所示:

/** ts(2345) 实参(Argument)与形参(Parameter)类型不兼容,不存在的属性 id */
Study({
  id: 2,
  name: 'TypeScript',
  age: () => new Date().getFullYear() - 2012
});

有意思的是,在上边的示例中,如果我们先把这个对象字面量赋值给一个变量,然后再把变量传递给函数进行调用,那么 TypeScript 静态类型检测就会仅仅检测形参类型中定义过的属性类型,而包容地忽略任何多余的属性,此时也不会抛出一个 ts(2345) 类型错误。

let ts = {
  id: 2,
  name: 'TypeScript',
  age: () => new Date().getFullYear() - 2012
};
Study(ts); // ok

这并非一个疏忽或 bug,而是有意为之地将对象字面量和变量进行区别对待,我们把这种情况称之为对象字面量的 freshness(在 12 讲中会再次详细介绍)。

因为这种内联形式的接口类型定义在语法层面与熟知的 JavaScript 解构颇为神似,所以很容易让我们产生混淆。下面我们通过如下示例对比一下解构语法与内联接口类型混用的效果。

/** 纯 JavaScript 解构语法 */
function StudyJavaScript({name, age}) {
  console.log(name, age);
}
/** TypeScript 里解构与内联类型混用 */
function StudyTypeScript({name, age}: {name: string, age: () => number}) {
    console.log(name, age);
}
/** 纯 JavaScript 解构语法,定义别名 */
function StudyJavaScript({name: aliasName}) { // 定义name的别名
  console.log(aliasName);
}
/** TypeScript */
function StudyTypeScript(language: {name: string}) {
  // console.log(name); // 不能直接打印name
  console.log(language.name);  
}

在函数中,对象解构和定义接口类型的语法很类似(如第 12 行和 17 行所示),注意不要混淆。实际上,定义内联的接口类型是不可复用的,所以我们应该更多地使用interface关键字来抽离可复用的接口类型

/ ** 关键字 接口名称 */
interface ProgramLanguage {
  /** 语言名称 */
  name: string;
  /** 使用年限 */
  age: () => number;
}

接口的语法格式是在 interface 关键字的空格后+接口名字,然后属性与属性类型的定义用花括弧包裹

function NewStudy(language: ProgramLanguage) {
  console.log(`ProgramLanguage ${language.name} created ${language.age()} years ago.`);
}

我们还可以通过复用接口类型定义来约束其他逻辑。比如,我们通过如下所示代码定义了一个类型为 ProgramLanguage 的变量 TypeScript 。

let TypeScript: ProgramLanguage;

接着,我们把满足接口类型约定的一个对象字面量赋值给了这个变量,不会报错。

TypeScript = {
  name: 'TypeScript',
  age: () => new Date().getFullYear() - 2012
}

而任何不符合约定的情况,都会提示类型错误。 如以下示例中额外多出了一个接口并未定义的属性 id,也会提示一个 ts(2322) 错误:对象字面量不能赋值给 ProgramLanguage 类型的变量 TypeScript。

TypeScript = {
  name: 'TypeScript',
  age: () => new Date().getFullYear() - 2012,
  id: 1
}

7.2可缺省属性

如果我们希望缺少 age 属性的对象字面量也能符合约定且不抛出类型错误,确切地说在接口类型中 age 属性可缺省,那么我们可以在属性名之后通过添加如下所示的? 语法来标注可缺省的属性或方法。

/** 关键字 接口名称 */
interface OptionalProgramLanguage {
  /** 语言名称 */
  name: string;
  /** 使用年限 */
  age?: () => number;
}
let OptionalTypeScript: OptionalProgramLanguage = {
  name: 'TypeScript'
}; // ok

当属性被标注为可缺省后,它的类型就变成了显式指定的类型与 undefined 类型组成的联合类型. 比如示例中 OptionalTypeScript 的 age 属性类型就变成了如下所示内容:

(() => number) | undefined;

发散思考一下:你觉得如下所示的接口类型 OptionalTypeScript2 和 OptionalTypeScript 等价吗?

/** 关键字 接口名称 */
interface OptionalProgramLanguage2 {
  /** 语言名称 */
  name: string;
  /** 使用年限 */
  age: (() => number) | undefined;
}

答案当然是不等价,这与 05 讲中提到函数可缺省参数和参数类型可以是 undefined 一样,可缺省意味着可以不设置属性键名类型是 undefined 意味着属性键名不可缺省

既然值可能是 undefined ,如果我们需要对该对象的属性或方法进行操作,就可以使用类型守卫(详见 11 讲)或 Optional Chain(在第 5 行的属性名后加 ? ),如下代码所示:

if (typeof OptionalTypeScript.age === 'function') {
  OptionalTypeScript.age();
}
OptionalTypeScript.age?.();

7.3只读属性

我们可以在属性名前通过添加 readonly 修饰符的语法来标注 name 为只读属性。

interface ReadOnlyProgramLanguage {
  /** 语言名称 */
  readonly name: string;
  /** 使用年限 */
  readonly age: (() => number) | undefined;
}
 
let ReadOnlyTypeScript: ReadOnlyProgramLanguage = {
  name: 'TypeScript',
  age: undefined
}
/** ts(2540)错误,name 只读 */
ReadOnlyTypeScript.name = 'JavaScript';

需要注意的是,这仅仅是静态类型检测层面的只读,实际上并不能阻止对对象的篡改。因为在转译为 JavaScript 之后,readonly 修饰符会被抹除。因此,任何时候与其直接修改一个对象,不如返回一个新的对象👍,这会是一种比较安全的实践。

7.4定义函数类型

接口类型不仅能用来定义对象的类型,接口类型还可以用来定义函数的类型 (备注:仅仅是定义函数的类型,而不包含函数的实现)

interface StudyLanguage {
  (language: ProgramLanguage): void
}
/** 单独的函数实践 */
let StudyInterface: StudyLanguage 
  = language => console.log(`${language.name} ${language.age()}`);

实际上,我们很少使用接口类型来定义函数的类型,更多使用内联类型或类型别名(本讲后半部分讲解)配合箭头函数语法来定义函数类型,具体示例如下:

type StudyLanguageType = (language: ProgramLanguage) => void

我们给箭头函数类型指定了一个别名 StudyLanguageType,在其他地方就可以直接复用 StudyLanguageType,而不用重新声明新的箭头函数类型定义。

7.5索引签名

在实际工作中,使用接口类型较多的地方是对象,比如 React 组件的 Props & State、HTMLElement 的 Props,这些对象有一个共性,即所有的属性名、方法名都确定

实际上,我们经常会把对象当 Map 映射使用,比如下边代码示例中定义了索引是任意数字的对象 LanguageRankMap 和索引是任意字符串的对象 LanguageMap。

let LanguageRankMap = {
  1: 'TypeScript',
  2: 'JavaScript',
  ...
};
let LanguageMap = {
  TypeScript: 2012,
  JavaScript: 1995,
  ...
};

这个时候,我们需要使用索引签名来定义上边提到的对象映射结构,并通过 “[索引名: 类型]”的格式约束索引的类型。

索引名称的类型分为 string 和 number 两种,通过如下定义的 LanguageRankInterface 和 LanguageYearInterface 两个接口,我们可以用来描述索引是任意数字或任意字符串的对象。

interface LanguageRankInterface {
  [rank: number]: string;
}
interface LanguageYearInterface {
  [name: string]: number;
}
{
  let LanguageRankMap: LanguageRankInterface = {
    1: 'TypeScript', // ok
    2: 'JavaScript', // ok
    'WrongINdex': '2012' // ts(2322) 不存在的属性名
  };
  
  let LanguageMap: LanguageYearInterface = {
    TypeScript: 2012, // ok
    JavaScript: 1995, // ok
    1: 1970 // ok
  };
}

注意:在上述示例中,数字作为对象索引时,它的类型既可以与数字兼容,也可以与字符串兼容,这与 JavaScript 的行为一致。因此,使用 0 或 '0' 索引对象时,这两者等价。

同样,我们可以使用 readonly 注解索引签名,此时将对应属性设置为只读就行

{
  interface LanguageRankInterface {
    readonly [rank: number]: string;
  }
  
  interface LanguageYearInterface {
    readonly [name: string]: number;
  }
} 

注意:虽然属性可以与索引签名进行混用,但是属性的类型必须是对应的数字索引或字符串索引的类型的子集,否则会出现错误提示。

{
  interface StringMap {
    [prop: string]: number;
    age: number; // ok
    name: string; // ts(2411) name 属性的 string 类型不能赋值给字符串索引类型 number
  }
  interface NumberMap {
    [rank: number]: string;
    1: string; // ok
    0: number; // ts(2412) 0 属性的 number 类型不能赋值给数字索引类型 string
  }
  interface LanguageRankInterface {
    name: string; // ok
    0: number; // ok
    [rank: number]: string;
    [name: string]: number;
  }
}

因为接口 StringMap 属性 name 的类型 string 不是它所对应的字符串索引(第 3 行定义的 prop: string)类型 number 的子集,所以会提示一个错误。同理,因为接口 NumberMap 属性 0 的类型 number 不是它所对应的数字索引(第 8 行定义的 rank: number)类型 string 的子集,所以也会提示一个错误。

另外,由于上边提到了数字类型索引的特殊性,所以我们不能约束数字索引属性与字符串索引属性拥有截然不同的类型

{
  interface LanguageRankInterface {
    [rank: number]: string; // ts(2413) 数字索引类型 string 类型不能赋值给字符串索引类型 number
    [prop: string]: number;
  }
}

这里我们定义了 LanguageRankInterface 的数字索引 rank 的类型是 string,与定义的字符串索引 prop 的类型 number 不兼容,所以会提示一个 ts(2413) 错误。

这里埋个伏笔:如果我们确实需要使用 age 是 number 类型、其他属性类型是 string 的对象数据结构,应该如何定义它的类型且不提示错误呢?

比如如下示例中定义的 age 属性是数字、其他任意属性是字符串的对象,我们应该怎么定义它的类型呢?


{
  age: 1, // 数字类型
  anyProperty: 'str', // 字符串
  ...
}

由于属性和索引签名的类型限制,使得我们不能通过单一的接口来描述这个对象,这时我们该怎么办呢?08 讲中我们会解决这个问题。

7.6继承与实现

在 TypeScript 中,接口类型可以继承和被继承,比如我们可以使用如下所示的 extends 关键字实现接口的继承。

{
  interface DynamicLanguage extends ProgramLanguage {
    rank: number; // 定义新属性
  }
  
  interface TypeSafeLanguage extends ProgramLanguage {
    typeChecker: string; // 定义新的属性
  }
  /** 继承多个 */
  interface TypeScriptLanguage extends DynamicLanguage, TypeSafeLanguage {
    name: 'TypeScript'; // 用原属性类型的兼容的类型(比如子集)重新定义属性
  }
}

注意:我们仅能使用兼容的类型覆盖继承的属性

{
  /** ts(6196) 错误的继承,name 属性不兼容 */
  interface WrongTypeLanguage extends ProgramLanguage {
    name: number;
  }
}

我们既可以使用接口类型来约束类,反过来也可以使用类实现接口,那两者之间的关系到底是什么呢?这里,我们通过使用如下所示的 implements关键字描述一下类和接口之间的关系。

/** 类实现接口 */
{
  class LanguageClass implements ProgramLanguage {
    name: string = '';
    age = () => new Date().getFullYear() - 2012
  }
}

7.7Type 类型别名

接口类型的一个作用是将内联类型抽离出来,从而实现类型可复用。其实,我们也可以使用类型别名接收抽离出来的内联类型实现复用。

此时,我们可以通过如下所示“type 别名名字 = 类型定义”的格式来定义类型别名。

/** 类型别名 */
{
  type LanguageType = {
    /** 以下是接口属性 */
    /** 语言名称 */
    name: string;
    /** 使用年限 */
    age: () => number;
  }
}

此外,针对接口类型无法覆盖的场景,比如组合类型、交叉类型(详见 08 讲),我们只能使用类型别名来接收,如下代码所示:

{
  /** 联合 */
  type MixedType = string | number;
  /** 交叉 */
  type IntersectionType = { id: number; name: string; } 
    & { age: number; name: string };
  /** 提取接口属性类型 */
  type AgeType = ProgramLanguage['age'];  
}

我们定义了一个 IntersectionType 类型别名,表示两个匿名接口类型交叉出的类型;同时定义了一个 AgeType 类型别名,表示抽取的 ProgramLanguage age 属性的类型。

7.8Interface 与 Type 的区别

适用接口类型标注的地方大都可以使用类型别名进行替代,这是否意味着在相应的场景中这两者等价呢?

实际上,在大多数的情况下使用接口类型和类型别名的效果等价,但是在某些特定的场景下这两者还是存在很大区别。比如,重复定义的接口类型,它的属性会叠加,这个特性使得我们可以极其方便地对全局变量、第三方库的类型做扩展,如下代码所示:

{
  interface Language {
    id: number;
  }
  
  interface Language {
    name: string;
  }
  let lang: Language = {
    id: 1, // ok
    name: 'name' // ok
  }
}

先后定义的两个 Language 「接口」属性被叠加在了一起,此时我们可以赋值给 lang 变量一个同时包含 id 和 name 属性的对象。

不过,如果我们重复定义类型别名,如下代码所示,则会提示一个 ts(2300) 错误。

{
  /** ts(2300) 重复的标志 */
  type Language = {
    id: number;
  }
  
  /** ts(2300) 重复的标志 */
  type Language = {
    name: string;
  }
  let lang: Language = {
    id: 1,
    name: 'name'
  }
}

接口类型是 TypeScript 最核心的知识点之一,掌握好接口类型,养成面向接口编程思维方式和惯性,将让我们的编程之路愈发顺利、高效。

类型别名使得类型可以像值一样能赋予另外一个变量(别名),大大提升了类型复用性,最终也提升了我们的编程效率。

8高级类型:联合类型和交叉类型

8.1联合类型

联合类型(Unions)用来表示变量、参数的类型不是单一原子类型,而可能是多种不同的类型的组合。

我们主要通过“|”操作符分隔类型的语法来表示联合类型。这里,我们可以把“|”类比为 JavaScript 中的逻辑或 “||”,只不过前者表示可能的类型。

function formatPX(size: unknown) {
  if (typeof size === 'number') {
    return `${size}px`;
  }
  if (typeof size === 'string') {
    return `${parseInt(size) || 0}px`;
  }
  throw Error(` 仅支持 number 或者 string`);
}
formatPX(13);
formatPX('13px');

说明:在学习联合类型之前,我们可能免不了使用 any 或 unknown 类型来表示参数的类型(为了让大家养成好习惯,推荐使用 unknown)。

通过这样的方式带来的问题是,在调用 formatPX 时,我们可以传递任意的值,并且可以通过静态类型检测(使用 any 亦如是),但是运行时还是会抛出一个错误,例如:

formatPX(true);
formatPX(null);

这显然不符合我们的预期,因为 size 应该是更明确的,即可能也只可能是 number 或 string 这两种可选类型的类型。

所幸有联合类型,我们可以使用一个更明确表示可能是 number 或 string 的联合类型来注解 size 参数,如下代码所示:

function formatPX(size: number | string) {
  // ...
}
formatPX(13); // ok
formatPX('13px'); // ok
formatPX(true); // ts(2345) 'true' 类型不能赋予 'number | string' 类型
formatPX(null); // ts(2345) 'null' 类型不能赋予 'number | string' 类型

当然,我们可以组合任意个、任意类型来构造更满足我们诉求的类型。

function formatUnit(size: number | string, unit: 'px' | 'em' | 'rem' | '%' = 'px') {
  // ...
}
formatUnit(1, 'em'); // ok
formatUnit('1px', 'rem'); // ok
formatUnit('1px', 'bem'); // ts(2345)

我们也可以使用类型别名抽离上边的联合类型,然后再将其进一步地联合,

type ModernUnit = 'vh' | 'vw';
type Unit = 'px' | 'em' | 'rem';
type MessedUp = ModernUnit | Unit; // 类型是 'vh' | 'vw' | 'px' | 'em' | 'rem'

我们也可以把接口类型联合起来表示更复杂的结构.

interface Bird {
  fly(): void;
  layEggs(): void;
}
interface Fish {
  swim(): void;
  layEggs(): void;
}
const getPet: () => Bird | Fish = () => {
  return {
   // ...
  } as Bird | Fish;
};
const Pet = getPet();
Pet.layEggs(); // ok
Pet.fly(); // ts(2339) 'Fish' 没有 'fly' 属性; 'Bird | Fish' 没有 'fly' 属性

在联合类型中,我们可以直接访问各个接口成员都拥有的属性、方法,且不会提示类型错误。但是,如果是个别成员特有的属性、方法,我们就需要区分对待了,此时又要引入类型守卫(详见 11 讲)来区分不同的成员类型。

只不过,在这种情况下,我们还需要使用基于 in 操作符判断的类型守卫

if (typeof Pet.fly === 'function') { // ts(2339)
  Pet.fly(); // ts(2339)
}
if ('fly' in Pet) {
  Pet.fly(); // ok
}

8.2交叉类型

在 TypeScript 中,还存在一种类似逻辑与行为的类型——交叉类型(Intersection Type),它可以把多个类型合并成一个类型,合并后的类型将拥有所有成员类型的特性。

在 TypeScript 中,我们可以使用“&”操作符来声明交叉类型,

{
  type Useless = string & number;
}

如果我们仅仅把原始类型、字面量类型、函数类型等原子类型合并成交叉类型,是没有任何用处的,因为任何类型都不能满足同时属于多种原子类型,因此,在上述的代码中,类型别名 Useless 的类型就是个 never。

8.3合并接口类型

联合类型真正的用武之地就是将多个接口类型合并成一个类型,从而实现等同接口继承的效果,也就是所谓的合并接口类型

type IntersectionType = { id: number; name: string; } 
    & { age: number };
  const mixed: IntersectionType = {
    id: 1,
    name: 'name',
    age: 18
  }

我们通过交叉类型,使得 IntersectionType 同时拥有了 id、name、age 所有属性,这里我们可以试着将合并接口类型理解为求并集

这里,我们来发散思考一下:如果合并的多个接口类型存在同名属性会是什么效果呢?

此时,我们可以根据同名属性的类型是否兼容(详见 12 讲)将这个问题分开来看。

如果同名属性的类型不兼容,比如上面示例中两个接口类型同名的 name 属性类型一个是 number,另一个是 string,合并后,name 属性的类型就是 number 和 string 两个原子类型的交叉类型,即 never,如下代码所示:

type IntersectionTypeConfict = { id: number; name: string; } 
    & { age: number; name: number; };
  const mixedConflict: IntersectionTypeConfict = {
    id: 1,
    name: 2, // ts(2322) 错误,'number' 类型不能赋给 'never' 类型
    age: 2
  };

如果同名属性的类型兼容,比如一个是 number,另一个是 number 的子类型、数字字面量类型,合并后 name 属性的类型就是两者中的子类型

如下所示示例中 name 属性的类型就是数字字面量类型 2,因此,我们不能把任何非 2 之外的值赋予 name 属性。

type IntersectionTypeConfict = { id: number; name: 2; } 
  & { age: number; name: number; };
  let mixedConflict: IntersectionTypeConfict = {
    id: 1,
    name: 2, // ok
    age: 2
  };
  mixedConflict = {
    id: 1,
    name: 22, // '22' 类型不能赋给 '2' 类型
    age: 2
  };

8.3合并联合类型

另外,我们可以合并联合类型为一个交叉类型,这个交叉类型需要同时满足不同的联合类型限制,也就是提取了所有联合类型的相同类型成员。这里,我们也可以将合并联合类型理解为求交集

在如下示例中,两个联合类型交叉出来的类型 IntersectionUnion 其实等价于 'em' | 'rem',所以我们只能把 'em' 或者 'rem' 字符串赋值给 IntersectionUnion 类型的变量。

type UnionA = 'px' | 'em' | 'rem' | '%';
  type UnionB = 'vh' | 'em' | 'rem' | 'pt';
  type IntersectionUnion = UnionA & UnionB;
  const intersectionA: IntersectionUnion = 'em'; // ok
  const intersectionB: IntersectionUnion = 'rem'; // ok
  const intersectionC: IntersectionUnion = 'px'; // ts(2322)
  const intersectionD: IntersectionUnion = 'pt'; // ts(2322)

既然是求交集,如果多个联合类型中没有相同的类型成员,交叉出来的类型自然就是 never 了

type UnionC = 'em' | 'rem';
  type UnionD = 'px' | 'pt';
  type IntersectionUnionE = UnionC & UnionD;
  const intersectionE: IntersectionUnionE = 'any' as any; // ts(2322) 不能赋予 'never' 类型

8.4联合、交叉组合

在前面的示例中,我们把一些联合、交叉类型抽离成了类型别名,再把它作为原子类型进行进一步的联合、交叉。其实,联合、交叉类型本身就可以直接组合使用,这就涉及 |、& 操作符的优先级问题。实际上,联合、交叉运算符不仅在行为上表现一致,还在运算的优先级和 JavaScript 的逻辑或 ||、逻辑与 && 运算符上表现一致 。

联合操作符 | 的优先级低于交叉操作符 &,同样,我们可以通过使用小括弧 () 来调整操作符的优先级。

type UnionIntersectionA = { id: number; } & { name: string; } | { id: string; } & { name: number; }; // 交叉操作符优先级高于联合操作符
  type UnionIntersectionB = ('px' | 'em' | 'rem' | '%') | ('vh' | 'em' | 'rem' | 'pt'); // 调整优先级

我们也可以把分配率、交换律等基本规则引入类型组合中,然后优化出更简洁、清晰的类型

 type UnionIntersectionC = ({ id: number; } & { name: string; } | { id: string; }) & { name: number; };
  type UnionIntersectionD = { id: number; } & { name: string; } & { name: number; } | { id: string; } & { name: number; }; // 满足分配率
  type UnionIntersectionE = ({ id: string; } | { id: number; } & { name: string; }) & { name: number; }; // 满足交换律

8.5类型缩减

如果将 string 原始类型和“string字面量类型”组合成联合类型会是什么效果?效果就是类型缩减成 string 了。 对于 number、boolean(其实还有枚举类型,详见第 9 讲)也是一样的缩减逻辑

type URStr = 'string' | string; // 类型是 string
  type URNum = 2 | number; // 类型是 number
  type URBoolen = true | boolean; // 类型是 boolean
  enum EnumUR {
    ONE,
    TWO
  }
  type URE = EnumUR.ONE | EnumUR; // 类型是 EnumUR

TypeScript 对这样的场景做了缩减,它把字面量类型、枚举成员类型缩减掉,只保留原始类型、枚举类型等父类型,这是合理的“优化”。

可是这个缩减,却极大地削弱了 IDE 自动提示的能力

 type BorderColor = 'black' | 'red' | 'green' | 'yellow' | 'blue' | string; // 类型缩减成 string

在上述代码中,我们希望 IDE 能自动提示显示注解的字符串字面量,但是因为类型被缩减成 string,所有的字符串字面量 black、red 等都无法自动提示出来了。 不要慌,TypeScript 官方其实还提供了一个黑魔法,它可以让类型缩减被控制。如下代码所示,我们只需要给父类型添加“& {}”即可。

  type BorderColor = 'black' | 'red' | 'green' | 'yellow' | 'blue' | string & {}; // 字面类型都被保留

此时,其他字面量类型就不会被缩减掉了,在 IDE 中字符串字面量 black、red 等也就自然地可以自动提示出来了。

此外,当联合类型的成员是接口类型,如果满足其中一个接口的属性是另外一个接口属性的子集,这个属性也会类型缩减,如下代码所示:

 type UnionInterce =
  | {
      age: '1';
    }
  | ({
      age: '1' | '2';
      [key: string]: string;
    });

这里因为 '1' 是 '1' | '2' 的子集,所以 age 的属性变成 '1' | '2'.

利用这个特性,我们来实现 07 讲中埋下的那个伏笔,如何定义如下所示 age 属性是数字类型,而其他不确定的属性是字符串类型的数据结构的对象?

{
  age: 1, // 数字类型
  anyProperty: 'str', // 其他不确定的属性都是字符串类型
  ...
}

在这里提到这个伏笔,想必你应该明白了,我们肯定要用到两个接口的联合类型及类型缩减,这个问题的核心在于找到一个既是 number 的子类型,这样 age 类型缩减之后的类型就是 number;同时也是 string 的子类型,这样才能满足属性和 string 索引类型的约束关系。

哪个类型满足这个条件呢?我们一起回忆一下 02 讲中介绍的特殊类型 never。

never 有一个特性是它是所有类型的子类型,自然也是 number 和 string 的子类型,所以答案如下代码所示:

type UnionInterce =
  | {
      age: number;
    }
  | ({
      age: never;
      [key: string]: string;
    });
  const O: UnionInterce = {
    age: 2,
    string: 'string'
  };

学习和掌握联合和交叉类型后,可以培养我们抽离、复用公共类型的意识和能力。

9枚举类型:详解常见枚举类型的 7 种用法

一种兼具语义化和简洁值优点的类型,用来表示一个被命名的整型常数的集合

9.1枚举类型

在 JavaScript 原生语言中并没有与枚举匹配的概念,而 TypeScript 中实现了枚举类型(Enums),这就意味着枚举也是 TypeScript 特有的语法(相对于 JavaScript)。

在 TypeScript 中,我们可以使用枚举定义包含被命名的常量的集合,比如 TypeScript 支持数字、字符两种常量值的枚举类型

我们也可以使用 enum 关键字定义枚举类型,格式是 enum + 枚举名字 + 一对花括弧,花括弧里则是被命名了的常量成员。

enum Day {
    SUNDAY,
    MONDAY,
    TUESDAY,
    WEDNESDAY,
    THURSDAY,
    FRIDAY,
    SATURDAY
  }  

注意:相对于其他类型,enum 也是一种比较特殊的类型,因为它兼具值和类型于一体,有点类似 class(在定义 class 结构时, 其实我们也自动定义了 class 实例的类型)。

在上述示例中,Day 既可以表示集合,也可以表示集合的类型,所有成员(enum member)的类型都是 Day 的子类型。

前边我们说过,JavaScript 中其实并没有与枚举类型对应的原始实现,而 TypeScript 转译器会把枚举类型转译为一个属性为常量、命名值从 0 开始递增数字映射的对象,在功能层面达到与枚举一致的效果(然而不是所有的特性在 JavaScript 中都有对应的实现)。

转译为 JavaScript 后的效果:

var Day = void 0;
    (function (Day) {
        Day[Day["SUNDAY"] = 0] = "SUNDAY";
        Day[Day["MONDAY"] = 1] = "MONDAY";
        Day[Day["TUESDAY"] = 2] = "TUESDAY";
        Day[Day["WEDNESDAY"] = 3] = "WEDNESDAY";
        Day[Day["THURSDAY"] = 4] = "THURSDAY";
        Day[Day["FRIDAY"] = 5] = "FRIDAY";
        Day[Day["SATURDAY"] = 6] = "SATURDAY";
    })(Day || (Day = {}));

在 TypeScript 中,我们可以通过“枚举名字.常量命名”的格式获取枚举集合里的成员

function work(d: Day) {
    switch (d) {
      case Day.SUNDAY:
      case Day.SATURDAY:
        return 'take a rest';
      case Day.MONDAY:
      case Day.TUESDAY:
      case Day.WEDNESDAY:
      case Day.THURSDAY:
      case Day.FRIDAY:
        return 'work hard';
    }
  }

work 函数转译为 JavaScript 后,里面的 switch 分支运行时的效果实际上等价于如下所示代码:

...
    switch (d) {
      case 0:
      case 1:
        return 'take a rest';
      case 2:
      case 3:
      case 4:
      case 5:
      case 6:
        return 'work hard';
    }
...

这就意味着在 JavaScript 中调用 work 函数时,传递的参数无论是 enum 还是数值,逻辑上将没有区别,当然这也符合 TypeScript 静态类型检测规则

 work(Day.SUNDAY); // ok
 work(0); // ok

这里我们既可以把枚举成员 Day.SUNDAY 作为 work 函数的入参,也可以把数字字面量 0 作为 work 函数的入参。

下面我们就来详细介绍一下 7 种常见的枚举类型:数字类型、字符串类型、异构类型、常量成员和计算(值)成员、枚举成员类型和联合枚举、常量枚举、外部枚举。

9.2数字枚举

从上边示例可知,在仅仅指定常量命名的情况下,我们定义的就是一个默认从 0 开始递增的数字集合,称之为数字枚举。

如果我们希望枚举值从其他值开始递增,则可以通过“常量命名 = 数值” 的格式显示指定枚举成员的初始值

enum Day {
    SUNDAY = 1,
    MONDAY,
    TUESDAY,
    WEDNESDAY,
    THURSDAY,
    FRIDAY,
    SATURDAY
  }

事实上,我们可以给 SUNDAY 指定任意类型(比如整数、负数、小数等)、任意起始的数字,其后未显示指定值的成员会递增加 1。上边的示例转译为 JavaScript 之后,则是一个属性值从 1 开始递增的对象,

var Day = void 0;
    (function (MyDay) {
        Day[Day["SUNDAY"] = 1] = "SUNDAY";
        Day[Day["MONDAY"] = 2] = "MONDAY";
        ...
        Day[Day["SATURDAY"] = 7] = "SATURDAY";
    })(Day || (Day = {}));

这里 Day.SUNDAY 被赋予了 1 作为值,Day.SATURDAY 则被赋予了 7 作为值。

当然我们也可以给任意位置的成员指定值,如下所示示例:

enum Day {
    SUNDAY,
    MONDAY,
    TUESDAY,
    WEDNESDAY,
    THURSDAY,
    FRIDAY,
    SATURDAY = 5
  } 

这里我们给最后一个成员 SATURDAY 指定了初始值 5,但转译后的结果

...
        Day[Day["FRIDAY"] = 5] = "FRIDAY";
        Day[Day["SATURDAY"] = 5] = "SATURDAY";
  ...

我们可以看到 MyDay.FRIDAY 和 MyDay.SATURDAY 的值都是数字 5,这就导致使用 Day 枚举作为 switch 分支条件的函数 work,在接收 MyDay.SATURDAY 作为入参时,也会进入 MyDay.FRIDAY 的分支,从而出现逻辑错误

这个经验告诉我们,由于枚举默认的值自递增且完全无法保证稳定性,所以给部分数字类型的枚举成员显式指定数值或给函数传递数值而不是枚举类型作为入参都属于不明智的行为

enum Day {
    ...
    SATURDAY = 5 // bad
  } 
  work(5); // bad

此外,常量命名、结构顺序都一致的两个枚举,即便转译为 JavaScript 后,同名成员的值仍然一样(满足恒等 === )。但在 TypeScript 看来,它们不相同、不满足恒等

 enum MyDay {
    SUNDAY,
    ...
  } 
  
  Day.SUNDAY === MyDay.SUNDAY; // ts(2367) 两个枚举值恒不相等
  work(MyDay.SUNDAY); // ts(2345) 'MyDay.SUNDAY' 不能赋予 'Day'

如果我们拿 MyDay 和 Day 的成员进行比较(第 6 行),或者把 MyDay 传值给形参是 Day 类型的 work 函数(第 7 行),就会发现都会提示错误。

不仅仅是数字类型枚举,所有其他枚举都仅和自身兼容,这就消除了由于枚举不稳定性可能造成的风险,所以这是一种极其安全的设计。不过,这可能会使得枚举变得不那么好用,因为不同枚举之间完全不兼容,所以不少 TypeScript 编程人员觉得枚举类型是一种十分鸡肋的类型。而两个结构完全一样的枚举类型如果互相兼容,则会更符合我们的预期,比如说基于 Swagger 自动生成的不同模块中结构相同且描述同一个常量集合的多个同名枚举。

不过,此时我们可能不得不使用类型断言(as)或者重构代码将“相同“的枚举类型抽离为同一个公共的枚举(我们更推荐后者)。

9.3字符串枚举

将定义值是字符串字面量的枚举称之为字符串枚举

enum Day {
    SUNDAY = 'SUNDAY',
    MONDAY = 'MONDAY',
    ...
  }

转译后,

var Day = void 0;
    (function (Day) {
        Day["SUNDAY"] = "SUNDAY";
        Day["MONDAY"] = "MONDAY";
    })(Day || (Day = {}));

9.4异构枚举(Heterogeneous enums)

TypeScript 支持枚举类型同时拥有数字和字符类型的成员,这样的枚举被称之为异构枚举。

enum Day {
    SUNDAY = 'SUNDAY',
    MONDAY = 2,
    ...
  }

枚举成员的值既可以是数字、字符串这样的常量,也可以是通过表达式所计算出来的值。这就涉及枚举里成员的一个分类,即常量成员和计算成员

9.5常量成员和计算(值)成员

在前边示例中,涉及的枚举成员的值都是字符串、数字字面量和未指定初始值从 0 递增数字常量,都被称作常量成员

另外,在转译时,通过被计算的常量枚举表达式定义值的成员,也被称作常量成员,比如如下几种情况:

引用来自预先定义的常量成员,比如来自当前枚举或其他枚举;

圆括弧 () 包裹的常量枚举表达式;

在常量枚举表达式上应用的一元操作符 +、 -、~ ;

操作常量枚举表达式的二元操作符 +、-、*、/、%、<<、>>、>>>、&、|、^。

除以上这些情况之外,其他都被认为是计算(值)成员

如下所示示例(援引自官方示例)中,除了 G 是计算成员之外,其他都属于常量成员。

 enum FileAccess {
    // 常量成员
    None,
    Read = 1 << 1,
    Write = 1 << 2,
    ReadWrite = Read | Write,
    // 计算成员
    G = "123".length,
  }

注意:关于常量成员和计算成员的划分其实比较难理解,实际上它们也并没有太大的用处,只是告诉我们通过这些途径可以定义枚举成员的值。因此,我们只需记住缺省值(从 0 递增)数字字面量字符串字面量肯定是常量成员就够了。

9.6枚举成员类型和联合枚举

另外,对于不需要计算(值)的常量类型成员,即缺省值(从 0 递增)、数字字面量、字符串字面量这三种情况(这就是为什么我们只需记住这三种情况),被称之为字面量枚举成员

前面我们提到枚举值和类型是一体的,枚举成员的类型是枚举类型的子类型

枚举成员和枚举类型之间的关系分两种情况: 如果枚举的成员同时包含字面量和非字面量枚举值,枚举成员的类型就是枚举本身(枚举类型本身也是本身的子类型);如果枚举成员全部是字面量枚举值,则所有枚举成员既是值又是类型

 enum Day {
    SUNDAY,
    MONDAY,
  }
  enum MyDay {
    SUNDAY,
    MONDAY = Day.MONDAY
  }
  const mondayIsDay: Day.MONDAY = Day.MONDAY; // ok: 字面量枚举成员既是值,也是类型
  const mondayIsSunday = MyDay.SUNDAY; // ok: 类型是 MyDay,MyDay.SUNDAY 仅仅是值
  const mondayIsMyDay2: MyDay.MONDAY = MyDay.MONDAY; // ts(2535),MyDay 包含非字面量值成员,所以 MyDay.MONDAY 不能作为类型

这里因为 Day 的所有成员都是字面量枚举成员,所以 Day.MONDAY 可以同时作为值和类型使用(第 11 行)。但是 MyDay 的成员 MONDAY 是非字面量枚举成员(但是是常量枚举成员),所以 MyDay.MONDAY 仅能作为值使用

另外,如果枚举仅有一个成员且是字面量成员,那么这个成员的类型等于枚举类型,如下代码所示:

enum Day {
  MONDAY
}
export const mondayIsDay: Day = Day.MONDAY; // ok
export const mondayIsDay1: Day.MONDAY = mondayIsDay as Day; // ok

因为枚举 Day 仅包含一个字面量成员 MONDAY,所以类型 Day 和 Day.MONDAY 可以互相兼容。比如第 4 行和第 5 行,我们既能把 Day.MONDAY 类型赋值给 Day 类型,也能把 Day 类型赋值给 Day.MONDAY 类型。

此外,回想 04 讲中介绍的字面量类型特性,不同成员的类型就是不同的字面量类型。纯字面量成员枚举类型也具有字面量类型的特性,也就等价于枚举的类型将变成各个成员类型组成的联合(枚举)类型

联合类型使得 TypeScript 可以更清楚地枚举集合里的确切值,从而检测出一些永远不会成立的条件判断(俗称 Dead Code)

 enum Day {
    SUNDAY,
    MONDAY,
  }
  
  const work = (x: Day) => {
    if (x !== Day.SUNDAY || x !== Day.MONDAY) { // ts(2367)
    }
  }

在上边示例中,TypeScript 确定 x 的值要么是 Day.SUNDAY,要么是 Day.MONDAY。因为 Day 是纯字面量枚举类型,可以等价地看作联合类型 Day.SUNDAY | Day.MONDAY,所以我们判断出第 7 行的条件语句恒为真,于是提示了一个 ts(2367) 错误。

不过,如果枚举包含需要计算(值)的成员情况就不一样了。如下示例中,TypeScript 不能区分枚举 Day 中的每个成员。因为每个成员类型都是 Day,所以无法判断出第 7 行的条件语句恒为真,也就不会提示一个 ts(2367) 错误。

 enum Day {
    SUNDAY = +'1',
    MONDAY = 'aa'.length,
  }
  
  const work = (x: Day) => {
    if (x !== Day.SUNDAY || x !== Day.MONDAY) { // ok
    }
  }

字面量类型所具有的类型推断、类型缩小的特性,也同样适用于字面量枚举类型,如下代码所示

enum Day {
    SUNDAY,
    MONDAY,
  }
  let SUNDAY = Day.SUNDAY; // 类型是 Day
  const SUNDAY2 = Day.SUNDAY; // 类型 Day.SUNDAY
  const work = (x: Day) => {
    if (x === Day.SUNDAY) {
      x; // 类型缩小为 Day.SUNDAY
    }
  }

在上述代码中,我们在第 5 行通过 let 定义了一个未显式声明类型的变量 SUNDAY,TypeScript 可推断其类型是 Day;在第 6 行通过 const 定义了一个未显式声明类型的变量 SUNDAY2,TypeScript 可推断其类型是 Day.SUNDAY;在第 8 行的 if 条件判断中,变量 x 类型也从 Day 缩小为 Day.SUNDAY。

9.7常量枚举(const enums)

我们可以通过添加 const 修饰符定义常量枚举,常量枚举定义转译为 JavaScript 之后会被移除,并在使用常量枚举成员的地方被替换为相应的内联值,因此常量枚举的成员都必须是常量成员(字面量 + 转译阶段可计算值的表达式)

const enum Day {
    SUNDAY,
    MONDAY
  }
  const work = (d: Day) => {
    switch (d) {
      case Day.SUNDAY:
        return 'take a rest';
      case Day.MONDAY:
        return 'work hard';
    }
  }
}

转译为成 JavaScript 后,Day 枚举的定义就被移除了,work 函数中对 Day 的引用也变成了常量值的引用(第 3 行内联了 0、第 5 行内联了 1)

var work = function (d) {
        switch (d) {
            case 0 /* SUNDAY */:
                return 'take a rest';
            case 1 /* MONDAY */:
                return 'work hard';
        }
    }; 

综上,使用常量枚举不仅能减少转译后的 JavaScript 代码量(因为抹除了枚举定义),还不需要到上级作用域里查找枚举定义(因为直接内联了枚举值字面量)。

因此,通过定义常量枚举,我们可以以清晰、结构化的形式维护相关联的常量集合,比如 switch case分支,使得代码更具可读性和易维护性。而且因为转译后抹除了定义、内联成员值,所以在代码的体积和性能方面并不会比直接内联常量值差。

9.8外部枚举(Ambient enums)

在 TypeScript 中,我们可以通过 declare 描述一个在其他地方已经定义过的变量

declare let $: any;
$('#id').addClass('show'); // ok

第 1 行我们使用 declare 描述类型是 any 的外部变量 ,在第2行则立即使用,在第 2 行则立即使用 ,此时并不会提示一个找不到 $ 变量的错误。

同样,我们也可以使用 declare 描述一个在其他地方已经定义过的枚举类型,通过这种方式定义出来的枚举类型,被称之为外部枚举

declare enum Day {
  SUNDAY,
  MONDAY,
}
const work = (x: Day) => {
  if (x === Day.SUNDAY) {
    x; // 类型是 Day
  }
}

这里我们认定在其他地方已经定义了一个 Day 这种结构的枚举,且 work 函数中使用了它。

转译为 JavaScript 之后,外部枚举的定义也会像常量枚举一样被抹除,但是对枚举成员的引用会被保留(第 2 行保留了对 Day.SUNDAY 的引用)

var work = function (x) {
    if (x === Day.SUNDAY) {
        x;
    }
};

外部枚举和常规枚举的差异在于以下几点:

在外部枚举中,如果没有指定初始值的成员都被当作计算(值)成员,这跟常规枚举恰好相反;

即便外部枚举只包含字面量成员,这些成员的类型也不会是字面量成员类型,自然完全不具备字面量类型的各种特性。

我们可以一起使用 declare 和 const 定义外部常量枚举,使得它转译为 JavaScript 之后仍像常量枚举一样。在抹除枚举定义的同时,我们可以使用内联枚举值替换对枚举成员的引用。

外部枚举的作用在于为两个不同枚举(实际上是指向了同一个枚举类型)的成员进行兼容、比较、被复用提供了一种途径,这在一定程度上提升了枚举的可用性

核心的几个知识点和建议:

  • 使用常量枚举管理相关的常量,能提高代码的可读性和易维护性;

  • 不要使用其他任何类型替换所使用的枚举成员;

  • 外部枚举一般会出现在类型声明文件(.d.ts)里,用来描述其他地方定义的枚举类型。举个例子,在 types.d.ts 里可以通过 declare enum A { ... } 描述在 business.ts 里真正定义的枚举 enum A { ... };这样 business.ts 的 enum A 和 types.d.ts 里的 enum A 就可以兼容了.外部枚举

10泛型

typescript最有意思的类型,我们可以使用泛型约束类型变量

两个问题:

  • 一个是如何使用 TypeScript 实现与 call(或者 apply) 功能类似的函数,重在考察候选人对泛型的应用;
  • 另一个是什么是泛型?泛型的作用是什么?重在考察候选人对泛型的理解。

泛型是 TypeScript 中非常基本、非常精华(有挑战)的特性,属于 TypeScript 入门(重在基础知识)和进阶(重在应用实践)之间衔接和升华的内容。

10.1什么是泛型?

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

10.2泛型类型参数

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

比如以下定义的一个 reflect 函数 ,它可以接收一个任意类型的参数,并原封不动地返回参数的值和类型,那我们该如何描述这个函数呢?好像得用上 unknown 了(其实我想说的是 any,因为 any is 魔鬼,所以还是用 unknown 吧)。

function reflect(param: unknown) {
  return param;
}
const str = reflect('string'); // str 类型是 unknown
const num = reflect(1); // num 类型 unknown

此时,reflect 函数虽然可以接收一个任意类型的参数并原封不动地返回参数的值,不过返回值类型不符合我们的预期。因为我们希望返回值类型与入参类型一一对应(比如 number 对 number、string 对 string),而不是无论入参是什么类型,返回值一律是 unknown。

此时,泛型正好可以满足这样的诉求,那如何定义一个泛型参数呢?首先,我们把参数 param 的类型定义为一个(类型层面的)参数、变量,而不是一个明确的类型,等到函数调用时再传入明确的类型。

比如我们可以通过尖括号 <> 语法给函数定义一个泛型参数 P,并指定 param 参数的类型为 P ,如下代码所示:

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

尖括号中的 P 表示泛型参数的定义,param 后的 P 表示参数的类型是泛型 P(即类型受 P 约束)。

我们也可以使用泛型显式地注解返回值的类型,虽然没有这个必要(因为返回值的类型可以基于上下文推断出来)。比如调用如下所示的 reflect 时,我们可以通过尖括号 <> 语法给泛型参数 P 显式地传入一个明确的类型。

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

然后在调用函数时,我们也通过 <> 语法指定了如下所示的 string、number 类型入参,相应地,reflectStr 的类型是 string,reflectNum 的类型是 number。


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 reflectArray<P>(param: P[]) {
  return param;
}
const reflectArr = reflectArray([1, '1']); // reflectArr 是 (string | number)[]

通过泛型,我们可以约束函数参数和返回值的类型关系。举一个我们比较熟悉的实际场景 React Hooks useState 为例,如下示例中,第 2 行 return 的元组(因为 useState 返回的是长度为 2、元素类型固定的数组)的第一个元素的类型就是泛型 S,第二个函数类型元素的参数类型也是泛型 S。

function useState<S>(state: S, initialValue?: S) {
  return [state, (s: S) => void 0] as unknown as [S, (s: S) => void];
}

注意:函数的泛型入参必须和参数/参数成员建立有效的约束关系才有实际意义。 比如在下面示例中,我们定义了一个仅约束返回值类型的泛型,它是没有任何意义的。

function uselessGenerics<P>(): P {
  return void 0 as unknown as P;
}

我们可以给函数定义任何个数的泛型入参

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

我们定义了一个拥有两个泛型入参(P 和 Q)的函数 reflectExtraParams,并通过 P 和 Q 约束函数参数 p1、p2 和返回值的类型。

10.3泛型类

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

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 类型

泛型类泛型函数类似的地方在于,在创建类实例时,如果受泛型约束的参数传入了明确值,则泛型入参(确切地说是传入的类型)可缺省,比如第 14 行、第 18 行,<number>、<string> 泛型入参就是可以缺省的。

对于 React 开发者而言,组件也支持泛型

function GenericCom<P>(props: { prop1: string }) {
  return <></>;
};
<GenericCom<{ name: string; }> prop1="1" ... />

在第 1 行~第 3 行,我们定义了一个泛型组件 GenericCom,它接收了一个类型入参 P。在第 4 行,通过 JSX 语法创建组件元素的同时,我们还显式指定了接口类型 { name: string } 作为入参。

10.4泛型类型

在 TypeScript 中,类型本身就可以被定义为拥有不明确的类型参数的泛型,并且可以接收明确类型作为入参,从而衍生出更具体的类型

const reflectFn: <P>(param: P) => P = reflect; // ok

这里我们为变量 reflectFn 显式添加了泛型类型注解,并将 reflect 函数作为值赋给了它。

我们也可以把 reflectFn 的类型注解提取为一个能被复用的类型别名或者接口

type ReflectFuncton = <P>(param: P) => P;
interface IReflectFuncton {
  <P>(param: P): P
}
const reflectFn2: ReflectFuncton = reflect;
const reflectFn3: IReflectFuncton = reflect;

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

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

这里我们定义了一个泛型,如果入参是 number | string 就会生成一个数组类型,否则就生成入参类型。而且,我们还使用了与 JavaScript 三元表达式完全一致的语法来表达类型运算的逻辑关系,15 讲中会更详细地介绍。

发散一下,如果我们给上面这个泛型传入了一个 string | boolean 联合类型作为入参,将会得到什么类型呢?

type BooleanOrString = string | boolean;
type WhatIsThis = StringOrNumberArray<BooleanOrString>; // 好像应该是 string | boolean ?
type BooleanOrStringGot = BooleanOrString extends string | number ? BooleanOrString[] : BooleanOrString; //  string | boolean

hover 类型别名 WhatIsThis ,那么你会发现显示的类型将是 boolean | string[]。

BooleanOrStringGot 和 WhatIsThis 这两个类型别名的类型居然不一样,这是什么逻辑?这个就是所谓的分配条件类型(Distributive Conditional Types)

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

比如上边示例中的 string | boolean 入参,先被拆解成 string 和 boolean 这两个独立类型,再分别判断是否是 string | number 类型的子集。因为 string 是子集而 boolean 不是,所以最终我们得到的 WhatIsThis 的类型是 boolean | string[]。

能接受入参的泛型类型和函数一样,都可以对入参类型进行计算并返回新的类型,像是在做类型运算

利用泛型,我们可以抽象封装出很多有用、复杂的类型约束。比如在 Redux Model 中约束 State 和 Reducers 的类型定义关系,我们可以通过如下所示代码定义了一个既能接受 State 类型入参,又包含 state 和 reducers 这两个属性的接口类型泛型,并通过 State 入参约束了泛型的 state 属性和 reducers 属性下 action 索引属性的类型关系。

interface ReduxModel<State> {
  state: State,
  reducers: {
    [action: string]: (state: State, action: any) => State
  }
}

然后根据实际需要,我们传入了一个具体的 State 类型具象化 ReduxModel,并约束了一个实际的 model

type ModelInterface = { id: number; name: string };
const model: ReduxModel<ModelInterface> = {
  state: { id: 1, name: 'xiaoming' }, //  ok 类型必须是 ModelInterface
  reducers: {
    setId: (state, action: { payload: number }) => ({
      ...state,
      id: action.payload // ok must be number
    }),
    setName: (state, action: { payload: string }) => ({
      ...state,
      name: action.payload // ok must be string
    })
  }
}

model 对象的 state 属性、reducers 属性的 setId、setName 方法的第一个参数 state 的类型都受到 ReduxModel 泛型入参 ModelInterface 的约束。

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

10.5泛型约束

前面提到了泛型就像是类型的函数,它可以抽象、封装并接收(类型)入参,而泛型的入参也拥有类似函数入参的特性。因此,我们可以把泛型入参限定在一个相对更明确的集合内,以便对入参进行约束

比如最前边提到的原封不动返回参数的 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'

我们限定了泛型入参只能是 number | string | boolean 的子集。

同样,我们也可以把接口泛型入参约束在特定的范围内

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)

在上述示例中,ReduxModelSpecified 泛型仅接收 { id: number; name: string } 接口类型的子类型作为入参。

我们还可以在多个不同的泛型入参之间设置约束关系

 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 操作符,15 讲中我们会详细介绍 TypeScript 类型相关的操作符)。

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

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) 缺少一个类型参数

我们定义了入参有默认类型的泛型 ReduxModelSpecified2,因此使用 ReduxModelSpecified2 时类型入参可缺省。而 ReduxModelSpecified 的入参没有默认值,所以缺省入参时会提示一个类型错误。

泛型入参的约束与默认值还可以组合使用

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

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

10.6小结

我们可以试着将泛型理解为类型中的函数,并通过抽象、封装类型运算逻辑实现类型可复用,以便更好地掌握泛型。

思考题:如何使用 TypeScript 实现 call?

提示: Parameters、ReturnType。这些关键词与 Array 一样,都是 TypeScript 内封装好的泛型(第 14 讲里会介绍官方内置泛型),你可以在谷歌、百度搜索引擎或者官方文档中搜索一下 “TypeScript + 关键词” 提前获取更多信息。