《Effective TypeScript》读书笔记(二)

第二章:TypeScript 的类型系统

第6条 使用你的编辑器来询问和浏览类型系统

第7条 将类型看作是值的集合

基本类型

let x: never; // never 类型,对应空集 {}
let x: never = 12; // 报错,因为空集不包含 12

type A = 'A'; // 单值集合 {'A'}
type B = 'B'; // 单值集合 {'B'}
type Twelve = 12;; // 单值集合 {12}

type AB = 'A' | 'B'; // 集合 {'A','B'},也可以看成是 {'A'} 和 {'B'} 的并集
type A_B = 'A' & 'B'; // never 类型,对应空集 {},也可以看成是 {'A'} 和 {'B'} 的交集
type AB12 = 'A' | 'B' | 12; // 集合 {'A','B',12}

const a: AB = 'A'; // {'A'} 是 {'A','B'} 的子集
const c: AB = 'C'; // 报错,因为 {'C'} 不是 {'A','B'} 的子集
const ab: AB = Math.random() < 0.5 ? 'A' : 'B'; // {'A','B'} 是 {'A','B'} 的子集

type Int = 1 | 2 | 3 | 4 | 5 ... // 整型相当于无穷多整数的集合

interface

和基本类型相比,interface 稍微不同。

interface Vector1D { x: number; }
interface Vector2D extends Vector1D { y: number; }
interface Vector3D extends Vector2D { z: number; }

如果不使用 extends,上面的类型定义可以写成如下形式:

interface Vector1D { x: number; }
interface Vector2D { x: number; y: number; }
interface Vector3D { x: number; y: number; z: number; }

如果把 interface 看成是集合,那么 Vector3DVector2D 的子集,Vector2DVector1D 的子集,可以表示为:

Vector3DVector2DVector1DVector3D \subseteq Vector2D \subseteq Vector1D

属性越多能表示的集合越小,属性越少能概括的集合越大。两个 interface 的交集包含它们两者所有的属性,并集拥有它们共同的属性,如果没有共同属性,那么并集为空集。例如以下代码:

interface Person {
  name: string;
}

interface Lifespan {
  birth: Date;
  death?: Date;
}

type PersonSpan = Person & Lifespan;

const ps: PersonSpan = {
  name: 'Alan Turing',
  birth: new Date('1912/06/23'),
  death: new Date('1954/06/07'),
};

type K = keyof Person | Lifespan; // never

PersonSpanPersonLifespan 的交集,也是子集。另外也可以用 extends 表示:

interface Person {
  name: string;
}

interface PersonSpan extends Person {
  birth: Date;
  death?: Date;
}

我们还可以借助 keyof 进一步加深对 interface 的理解。

两个 interface 没有共同属性:

interface A {
  value: string;
}

interface B {
  message: string;
}

type C = keyof (A & B); // 'value' | 'message'
type D = keyof (A | B); // never
type E = (keyof A) | (keyof B); // 'value' | 'message'
type F = (keyof A) & (keyof B); // never

两个 interface 有共同属性:

interface A {
  code: number;
  value: string;
}

interface B {
  code: number;
  message: string;
}

type C = keyof (A & B); // 'code' | 'value' | 'message'
type D = keyof (A | B); // 'code'
type E = (keyof A) | (keyof B); // 'code' | 'value' | 'message'
type F = (keyof A) & (keyof B); // 'code'

我们可以发现存在这样的交换律:

keyof (A & B) = (keyof A) | (keyof B)
keyof (A | B) = (keyof A) & (keyof B)

extends

extends 除了用于对象继承,还可以用来约束泛型类型,例如以下代码:

function getKey<K extends string>(val: any, key: K) {
  // ...
}

getKey({}, 'x'); // OK, 'x' extends string
getKey({}, Math.random() < 0.5 ? 'a' : 'b'); // OK, 'a'|'b' extends string
getKey({}, document.title); // OK, string extends string
getKey({}, 12);
               // Argument of type 'number' is not assignable to parameter
               // of type 'string'.

其中 Kstring 的子集。

interface Point {
  x: number;
  y: number;
}

type PointKeys = keyof Point; // Type is "x" | "y"

function sortBy<K extends keyof T, T>(vals: T[], key: K): T[] {
  // ...

}
const pts: Point[] = [{ x: 1, y: 1 }, { x: 2, y: 0 }];
sortBy(pts, 'x'); // OK, 'x' extends 'x'|'y' (aka keyof T)
sortBy(pts, 'y'); // OK, 'y' extends 'x'|'y'
sortBy(pts, Math.random() < 0.5 ? 'x' : 'y'); // OK, 'x'|'y' extends 'x'|'y'
sortBy(pts, 'z');
                 // Argument of type '"z"' is not assignable to parameter
                 // of type 'keyof Point'.

其中 KPoint 所有 key 的子集。

数组(Array)和元组(Tuple)

const list = [1, 2]; // Type is number[]
const tuple: [number, number] = list;
              // Type 'number[]' is not assignable to type '[number, number]'.
              // Target requires 2 element(s) but source may have fewer.
const triple: [number, number, number] = [1, 2, 3];
const double: [number, number] = triple;
// Type '[number, number, number]' is not assignable to type '[number, number]'.
// Source has 3 element(s) but target allows only 2.

Exclude

可以使用 Exclude 排除一些类型:

type T = Exclude<string | Date, string | number>; // Type is Date
type NonZeroNums = Exclude<number, 0>; // Type is still just number

TypeScript 和集合理论术语对照

TypeScript 术语 集合术语
never ∅ (空集)
字面量类型 单元素集合
Value 可赋值给 T Value ∈ T (属于)
T1 可赋值给 T2 T1 ⊆ T2 (子集)
T1 extends T2 T1 ⊆ T2 (子集)
T1 T2
T1 & T2 T1 ∩ T2 (交集)
unknown 全集

第8条 区分类型空间还是值空间

TypeScript 中的符号存在两种空间:

  • 类型空间(Type Space)
  • 值空间(Value Space)

不同关键词定义的符号可能属于不同空间,最常见的比如:

  • typeinterface 引入类型
  • constlet 引入值
  • classenum 引入类型和值

可以用下面的代码来检验我们对类型和值的判断:

class Cylinder {
  radius = 1;
  height = 1;
}

const c = new Cylinder();
type U = typeof c; // Type is Cylinder

const v = typeof Cylinder; // Value is "function"
type T = typeof Cylinder; // Type is typeof Cylinder
type C = InstanceType<typeof Cylinder>; // Type is Cylinder

第9条 倾向于类型声明而不是类型断言

interface Person {
  name: string
};

const alice: Person = {};
  // ~~~~~ Property 'name' is missing in type '{}'
  // but required in type 'Person'

const bob = {} as Person;

const rose: Person = {
  name: 'Alice',
  occupation: 'TypeScript developer'
  // ~~~~~~~~~ Object literal may only specify known properties
  // and 'occupation' does not exist in type 'Person'
};

const jack = {
  name: 'Bob',
  occupation: 'JavaScript developer'
} as Person;

相比类型断言,类型声明可以获得更好类型检查。

第10条 避免使用对象包装类型(String, Number, Boolean, Symbol, BigInt)

以字符串为例:

'primitive'.charAt(3); // "m"

作为基本类型,字符串字面量本身不包含方法,在调用时 JavaScript 会自动将它包装成 String 对象,调用完后重新返回基本类型,可以通过如下方式验证:

const originalCharAt = String.prototype.charAt;

String.prototype.charAt = function (pos) {
  console.log(this, typeof this, pos);
  return originalCharAt.call(this, pos);
};

console.log('primitive'.charAt(3));

执行结果:

String {"primitive"} "object" 3
m

字符串基本类型和字符串对象并不相等:

"hello" === "hello"; // true
"hello" === new String("hello"); // false
new String("hello") === new String("hello"); // false

字符串基本类型的隐式包装类型转换,可以解释以下古怪现象:

> var x = "hello"
> x.language = 'English'
'English'
> x.language
undefined

在 TypeScript 中,可以将字符串基本类型 string 赋值给包装类型 String,但无法反过来将 String 赋值给 string

function getStringLen(foo: String) {
  return foo.length;
}

getStringLen("hello"); // OK
getStringLen(new String("hello")); // OK
function isGreeting(phrase: String) {
  return [
    'hello',
    'good day'
  ].includes(phrase);
            // ~~~~~~
            // Argument of type 'String' is not assignable to parameter
            // of type 'string'.
            // 'string' is a primitive, but 'String' is a wrapper object;
            // prefer using 'string' when possible
}