类型别名与字面量类型_TypeScript笔记10

一.类型别名

type PersonName = string;
type PhoneNumber = string;
type PhoneBookItem = [PersonName, PhoneNumber];
type PhoneBook = PhoneBookItem[];

let book: PhoneBook = [
  ['Lily', '1234'],
  ['Jean', '1234']
];

type关键字能为现有类型创建一个别名,从而增强其可读性

接口与类型别名

类型形式上与接口有些类似,都支持类型参数,且可以引用自身,例如:

type Tree<T> = {
    value: T;
    left: Tree<T>;
    right: Tree<T>;
}

interface ITree<T> { 
  value: T;
  left: ITree<T>;
  right: ITree<T>;
}

但存在一些本质差异:

  • 类型别名并不会创建新类型,而接口会定义一个新类型

  • 允许给任意类型起别名,但无法给任意类型定义与之等价的接口(比如基础类型)

  • 无法继承或实现类型别名(也不能扩展或实现其它类型),但接口可以

  • 类型别名能将多个类型组合成一个具名类型,而接口无法描述这种组合(交叉、联合等)

// 类型组合,接口无法表达这种类型
type LinkedList<T> = T & { next: LinkedList<T> };

interface Person {
  name: string;
}
function findSomeone(people: LinkedList<Person>, name: string) {
  people.name;
  people.next.name;
  people.next.next.name;
  people.next.next.next.name;
}

应用场景上,二者区别如下:

  • 接口:OOP场景(因为能被继承和实现,维持着类型层级关系)

  • 类型别名:追求可读性的场景、接口无法描述的场景(基础类型、交叉类型、联合类型等)

二.字面量类型

存在两种字面量类型:字符串字面量类型与数值字面量类型

字符串

字符串字面量也具有类型含义,例如:

let x: 'string';
// 错误 Type '"a"' is not assignable to type '"string"'.
x = 'a';
// 正确
x = 'string';

可以用来模拟枚举的效果:

type Easing = 'ease-in' | 'ease-out' | 'ease-in-out';
class UIElement {
  animate(dx: number, dy: number, easing: Easing) {
    if (easing === 'ease-in') {}
    else if (easing === 'ease-out') {}
    else {
      // 自动缩窄到"ease-in-out"类型
    }
  }
}

// 错误 Argument of type '"linear"' is not assignable to parameter of type 'Easing'.
new UIElement().animate(0, 0, 'linear');

不同的字符串字面量属于不同的具体类型,因此,(如果必要的话)可以这样重载:

function createElement(tagName: "img"): HTMLImageElement;
function createElement(tagName: "input"): HTMLInputElement;
function createElement(tagName: string): Element {
  return document.createElement(tagName);
}

数值

数值字面量同样具有类型含义:

// 返回骰子的6个点数
function rollDice(): 1 | 2 | 3 | 4 | 5 | 6 {
  // ...
}

看起来只是个匿名数值枚举,似乎没什么存在必要

存在意义

实际上,字面量类型的意义在于编译时能够结合类型信息“推理”,例如:

function foo(x: number) {
  // 错误 This condition will always return 'true' since the types '1' and '2' have no overlap.
  if (x !== 1 || x !== 2) { }
}

function bar(x: string) {
  // 错误 This condition will always return 'false' since the types '"1"' and '"2"' have no overlap.
  if (x === '1' && x === '2') { 
    //...
  }
}

这种类型完整性补充让TypeScript能够更细致地“理解”(静态分析)代码含义,进而发现一些不那么直接的潜在问题

Nevertheless, by pairing a type with it’s unique inhabitant, singleton types bridge the gap between types and values.

三.枚举与字面量类型

我们知道有一种特殊的枚举叫联合枚举,其成员也具有类型含义,例如:

// 联合枚举
enum E {
  Foo,
  Bar,
}

// 枚举的类型含义
function f(x: E) {
  // 错误 This condition will always return 'true' since the types 'E.Foo' and 'E.Bar' have no overlap.
  if (x !== E.Foo || x !== E.Bar) {
    //...
  }
}

这与字面量类型中的例子非常相似:

function f(x: 'Foo' | 'Bar') {
  // 错误 This condition will always return 'true' since the types '"Foo"' and '"Bar"' have no overlap.
  if (x !== 'Foo' || x !== 'Bar') {
    //...
  }
}

P.S.类比起见,这里用字符串字面量联合类型('Foo' | 'Bar')模拟枚举E,实际上枚举E等价于数值字面量联合类型(0 | 1),具体见二.数值枚举

从类型角度来看,联合枚举就是由数值/字符串字面量构成的枚举,因此其成员也具有类型含义。名称上也表达了这种联系:联合枚举,即数值/字符串联合

P.S.枚举成员类型与数值/字符串字面量类型也叫单例类型(singleton types)

Singleton types, types which have a unique inhabitant.

也就是说,一个单例类型下只有一个值,例如字符串字面量类型'Foo'只能取值字符串'Foo'

四.可区分联合

结合单例类型、联合类型、类型保护和类型别名可以建立一种模式,称为可区分联合(discriminated unions)

P.S.可区分联合也叫标签联合(tagged unions)或代数数据类型(algebraic data types),即可运算、可进行逻辑推理的类型

具体地,可区分联合一般包括3部分:

  • 一些具有公共单例类型属性的类型——公共单例属性即可区分的特征(或者叫标签)
  • 一个指向这些类型构成的联合的类型别名——即联合
  • 针对公共属性的类型保护

通过区分公共单例属性的类型来缩窄父类型,例如:

// 1.一些具有公共单例属性(kind)的类型
interface Square {
    kind: "square";
    size: number;
}
interface Circle {
    kind: "circle";
    radius: number;
}
// 2.定义联合类型,并起个别名
type Shape = Square | Circle;
// 3.具体使用(类型保护)
function area(s: Shape) {
  switch (s.kind) {
    // 自动缩窄到Square
    case "square": return s.size * s.size;
    // 自动缩窄到Circle
    case "circle": return Math.PI * s.radius ** 2;
  }
}

算是对instanceof类型保护的一种补充,都用于检测复杂类型的兼容关系,区别如下:

  • instanceof类型保护:适用于有明确继承关系的父子类型

  • 可区分联合类型保护:适用于没有明确继承关系(运行时通过instanceof检测不出继承关系)的父子类型

完整性检查

有些时候可能想要完整覆盖联合类型的所有组成类型,例如:

type Shape = Square | Circle;
function area(s: Shape) {
  switch (s.kind) {
    case "square": return s.size * s.size;
    // 潜在问题:漏掉了"circle"
  }
}

可以通过never类型来实现这种保障(Never类型为数不多的应用场景之一):

function assertNever(x: never) {
  throw new Error("Unexpected object: " + x);
}
function area(s: Shape) {
  switch (s.kind) {
    case "square": return s.size * s.size;
    // case "circle": return s.radius * s.radius;
    // 错误 Argument of type 'Circle' is not assignable to parameter of type 'never'.
    default: return assertNever(s);
  }
}

如果没有完整覆盖,就会走到default分支把s: Shape传递给x: never引发类型错误(完整覆盖了的话,default就是不可达分支,不会引发never错误)。能够满足完整性覆盖要求,但需要额外定义一个assertNever函数

P.S.关于Never类型的更多信息,见基本类型_TypeScript笔记2

此外,还有一种不那么准确,但也有助于检查完整性的方法:开启--strictNullChecks选项,并标明函数返回值。利用默认返回undefined来保证完整性,例如:

// 错误 Function lacks ending return statement and return type does not include 'undefined'.
function area(s: Shape): number {
  switch (s.kind) {
    case "square": return s.size * s.size;
  }
}

实质上是非空返回值检测,不像assertNever是精确到switch粒度的,相对脆弱(有默认返回值,或有多个switch都会破坏完整性检查)

参考资料

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code