Changelogs
  • 2023年4月23日 - 开坑
  • 2023年4月24日 - 条款 1-8
  • 2023年4月25日 - 条款 9-11
  • 2023年4月26日 - 条款 12-13
  • 2023年4月27日 - 条款 14
  • 2023年5月1日 - 条款 15-

TL;DR

学习 Effective TypeScript 过程中的一些笔记,仅供个人参考,非教学用途。

第一章: 了解 TypeScript

条款1:理解 TypeScript 与 JavaScript 的关系

  • 如图所示,TypeScript 是 JavaScript 的超集。TS 包括一些自己的语法。
  • TypeScript 提供了一个类型系统:
    • 目标之一是在不运行代码的前提下检测在运行时会引发异常的代码。
    • 会根据自己的 品味 有选择性地对 JavaScript 的行为进行建模。
      • 正例:
        • const x = 2 + '3' // OK, "23"
      • 反例:
        • const a = null + 7 // TS Warn! 但是在 JS 里为7
        • const a = [] + 7 // TS Warn! 但是在 JS 里为7
        • 额外的函数参数个数校验
  • TS 的类型系统非常不具备 可靠性ReasonElm 具有可靠性,但均不是 JavaScript 的超集。

条款2:知道你在使用哪个 TypeScript 选项

可以通过 tsc --init 创建 tssconfig.json 选项文件。

三个重要的选项:

  • noImplicitAny:禁止隐式的 any 类型,建议尽可能开启,除非正在迁移旧代码。
  • strictNullChecks:启用严格的空值检查,如果关闭,相当于隐式的给所有类型加上了 | null | undefined。建议尽可能开启,除非正在迁移旧代码。
  • strict:启用所有严格的类型检查,包括 noImplicitAnystrictNullChecks

条款3:理解代码的生成是独立于类型的

宏观上,TS做了两件事情,这两个行为是 完全独立 的,类型不会影响生成代码的运行:

  1. 将 TS/JS 代码兼容转换输出成配置项指定的版本。
  2. 检查代码是否有类型错误。

可以把TS的错误类比成其他编程语言的警告

  • 表示代码很可能有问题,但并不会中止构建。
  • 不要说 编译不过,而应该说 代码有错误 或者 没有通过类型检查

TS的类型是可擦除的

无法直接用 instanceof 作用于一个TS的类型。

可以用一些方法在运行时重建类型:

  1. "xxx" in obj

  2. 标签联合类型

    interface Square {
      kind: "square";
      width: number;
    }
    interface Rectangle {
      kind: "rectangle";
      height: number;
      width: number;
    }
    type Shape = Square | Rectangle;
    
    // shape: Shape
    if (shape.kind === 'square') {
        // shape: retangle
    }
    
  3. 使用 class 同时引入一个类型和一个值。

类型不会影响运行时的值

// var: number | string
val as number // 类型断言 并没有对 val 进行类型转换

// 正确的做法
typeof(val) === 'string' ? Number(val) : val

运行类型可能与声明类型不一样。一个值可能具有与你声明的类型不同的类型

function fn(value: boolean) {
    switch (value) {
      case true:
        turnLightOn();
        break;
      case false:
        turnLightOff();
        break;
      default:
        console.log(`I'm afraid I can't do that.`); // 可能会执行到!
    }
}

场景:

  1. JS 里调用,如: fn("ON")

  2. TS里也能触发,比如来自 外部 的值(如网络返回):

    interface LightApiResponse {
      lightSwitchValue: boolean;
    }
    async function setLight() {
      const response = await fetch("/light");
      // await response.json(): any
      const result: LightApiResponse = await fetch("/light"); // 无法保证 lightSwitchValue 一定是 boolean
      setLightSwitch(result.lightSwitchValue);
    }
    

TS 里没有函数重载

JavaScript 中没有真正意义上的函数重载。在JavaScript中,同一个作用域,出现两个名字一样的函数,后面的会覆盖前面的,所以 JavaScript 没有真正意义的重载。不过可以通过 arguments 对象来实现类似的效果。

TS 提供了一个重载函数的工具(提供多个声明,但只有一个实现),但完全是类型级别上操作的,相比于传统编程语言的重载,有很多限制(请优先选择条件类型,而不是函数重载,条款50):

function add(a: number, b: number): number;
function add(a: string, b: string): string;

function add(a, b) {
  return a + b;
}

const three = add(1, 2); // Type is number
const twelve = add("1", "2"); // Type is string

TS 类型对运行时的性能没有影响

  • TS 类型系统在运行时完全擦除。
  • TS 会引入构建时的开销,特别是对于增量构建。可以使用 transpile only 选项跳过类型检查。
  • TS 的代码转义可能相比于原生实现,有性能差异。

条款4:习惯结构类型(Structural Typing)

JavaScript是鸭子类型(duck typed)的,鸭子类型是一种动态类型的概念,它指的是在程序中,只关注对象的行为(方法和属性),而不是对象的类型。如果一个对象具有所需的方法和属性,那么它被视为具有该类型,即使它的实际类型不同。换句话说,如果一个对象看起来像一只鸭子,叫起来像一只鸭子,那么它就可以被认为是一只鸭子。这意味着你可以使用一个对象来代替另一个对象,只要它们的行为相似。

对此,TypeScript使用结构类型(structural typing)进行建模。

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

interface NamedVector2D {
    name: string;
    x: number;
    y: number;
}

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

let a: Vector2D = { x: 1, y: 2 };
let b: NamedVector2D = { name: 'a', x: 1, y: 2 };
let c: Vector3D = { x: 1, y: 2, z: 3 };

a = b; // OK
a = c;  // OK
b = a; // Error

fn(a); // OK
fn(b); // OK
fn(c); // OK

function fn(v: Vector2D) {
    return v.x;
}

如果想让 TypeScript 对于 2D 和 3D 向量的混用进行报错,可以加入烙印(branding),见条款 37。

TS 类型不是密封的

下面代码的 axis 是 any 的,因为 v 可能是如第一行这样构造的。

let v: Vector3D = { x: 1, y: 2, z: 3, w: 'a' } as Vector3D

function calculateLength1(v: Vector3D) {
  let length = 0

  for (const axis of Object.keys(v)) {
      const coordinate = v[axis] // type: any
      length += coordinate ** 2
  }
  return length
}

如果想妥善的处理遍历对象时的类型问题,可以参考条款54。

Class 也是结构类型的

这一点与 Java/C++这种语言不同,因此如果你在构造函数中编写了某些逻辑并且编写了一个基于该逻辑会成立的函数,那么就可能存在一些意外。

结构类型可以很方便的用来单元测试

一方面函数的参数可以定义为瘦接口(只定义需要的接口)。另一方面,也可以方便的定义简单的 Mock 对象。

条款5:限制使用 any 类型

尽量不要使用any,以充分享受免费的类型检查器和语言服务(重构、自动补全)。使用 any会掩盖重构代码时的错误、遮蔽了类型设计,破坏了对类型系统的信心。

第二章: TypeScript的类型系统

条款6:使用你的编辑器来询问和探索类型系统

typescipt 附带两个可执行文件:

  • tsc,ts 编译器
  • tsserver,ts独立服务器(提供包括自动补全、检查、导航和重构等功能)

条款7:将类型视为值的集合

TS术语和集合术语的关系:

从集合论的角度理解下列等式:

interface Person {
  name: string; 
 }
 interface Lifespan {
  birth: Date;
  death?: Date; 
 }
 
 type PersonSpan = Person & Lifespan; // { name: string; birth: Date; death?: Date; }

Think of “extends,” “assignable to,” and “subtype of ” as synonyms for “subset of.”

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

T extends string :T 是任何 域 是 string域 的子集的类型

T extends keyof K

TypeScript 把 Tuple 建模为如下形式,所以不能把一个 triple 赋值给一个 double。

{
    0: number,
    1: number,
    length: 2
}

TypeScript 并不能精确地表示所有的类型,例子:

  1. 所有的整数

  2. 只有 x, y 属性而没有其他属性的对象。

  3. Exclude 可以用来做类型减法,但只有在恰好能产生一个合法的类型时才会成功

    type T = Exclude<string|Date, string|number>; // Type is Date
    type NonZeroNums = Exclude<number, 0>; // Type is still just number
    
    type Exclude<T, U> = T extends U ? never : T;
    

条款8:知道如何分辨符号是类型空间还是值空间

TS 中的一个符号必然是属于二选一:

  • 类型空间。一般来说,跟在 type 或者 interface 后面。
  • 值空间。一般来说,跟在 const 或者 let 声明后面。

一些讨论:

  • classenum 同时引入了类型和值。类型是基于它的形状(属性和方法),而值是 构造函数。

有许多事物在两个空间中具有不同的意义:

  • typeof 操作符,总是作用于值

    • 类型空间:typeof 接受一个值并返回其 TypeScript 类型。可以用 type 声明来给它命名。

    • 值空间:JS 的运行时运算符,返回字符串。

    • typeof 一个类,作用的 是这个类的构造函数:

      const v = typeof Cylinder; // "fuction"
      type T = typeof Cylinder; // type: 'typeof Cylinder',注意不是 Cylinder!
      
      declare let fn: T;
      const c = new fn(); // type: Cylinder
      
      type C = InstanceType<typeof Cylinder>; // type: Cylinder
      
      type InstanceType<T extends abstract new (...args: any) => any> = T extends abstract new (...args: any) => infer R ? R : any;
      
  • [] 属性访问器:

    • 类型空间:只能用 X[Y] 的形式。

      interface Person {
          first: string;
          last: string;
      }
      
      
      declare let p: Person;
      
      const first: Person['first'] = p['first'];
      type PersonEl = Person['first' | 'last'] // type: string
      type Tuple = [string, number, Date]
      type TupleEl = Tuple[number] // type: string | number | Date
      
    • 值空间:可以用 X.YX[Y] 的形式。

  • this

    • 类型空间(介绍 by GPT4):this 类型在 TypeScript 中是一个特殊的类型,它允许你引用当前对象的类型。在面向对象的程序设计中,对象通常会包含方法,这些方法在运行时可以访问和操作对象的数据。在 TypeScript 中,你可以使用 this 类型来表示方法所属的对象类型,从而可以更准确地进行类型检查和推断。

      • 链式调用:this 类型在实现链式调用(即在方法链中返回 this)时非常有用。链式调用允许你在单个表达式中连续调用多个方法。在 TypeScript 中,你可以使用 this 类型表示方法返回值为当前对象类型。

        class Calculator {
          protected value: number;
          constructor(initialValue: number) {
            this.value = initialValue;
          }
          add(operand: number): this {
            this.value += operand;
            return this;
          }
          subtract(operand: number): this {
            this.value -= operand;
            return this;
          }
          getResult(): number {
            return this.value;
          }
        }
        const calculator = new Calculator(10);
        const result = calculator.add(5).subtract(2).getResult(); // result is 13
        
      • 子类中保留方法签名:当一个类继承另一个类时,子类可以继承父类的方法。如果方法返回 this 类型,那么在子类中该方法的返回值也将自动更新为子类的类型。这允许子类重用父类方法,同时保持类型安全。请注意,在这个例子中,AdvancedCalculator 类继承了 Calculator 类,并添加了 multiplydivide 方法。由于 addsubtract 方法的返回类型为 this,因此在 AdvancedCalculator 实例上调用这些方法时,它们将返回 AdvancedCalculator 类型的实例,从而允许链式调用 multiplydivide 方法。

        class AdvancedCalculator extends Calculator {
          multiply(operand: number): this {
            this.value *= operand;
            return this;
          }
        
          divide(operand: number): this {
            this.value /= operand;
            return this;
          }
        }
        
        const advancedCalculator = new AdvancedCalculator(10);
        const result = advancedCalculator.add(5).multiply(2).divide(3).getResult(); // result is 10
        
      • 紧凑的函数类型定义:在类型定义中,你可以使用 this 类型表示函数的调用者。这可以使类型定义更简洁和清晰。在这个例子中,StringFormatter 接口定义了一个 format 方法,它接受一个 this 类型的参数。这表示实现 StringFormatter 接口的函数应该将其 this 上下文绑定到一个字符串。在 uppercaseFormatter函数中,我们使用了this类型来表示一个字符串,并使用toUpperCase方法将其转换为大写。由于this类型在StringFormatter 的定义中被指定为字符串,因此我们可以确保在实现此接口的函数中,this 将始终引用一个字符串。

        interface StringFormatter {
          format(this: string): string;
        }
        
        const uppercaseFormatter: StringFormatter = function() {
          return this.toUpperCase();
        };
        
        const formatted = uppercaseFormatter.format.call("hello"); // formatted is "HELLO"
        
    • 值空间:this 关键词,详见条款49。

  • &|

    • 类型空间:交集和联合。
    • 值空间:按位与 和 按位或。
  • const

    • 类型空间:as const 改变了推断类型(见条款21)。
    • 值空间:定义常量。
  • extends

    • 类型空间:定义子类型,泛型约束。
    • 值空间:定义子类。
  • in

    • 类型空间:映射类型(见条款14)。
    • 值空间:遍历对象。

一个新手误区(by GPT3.5)
function email({
 person: Person,
 // ~~~~~~ Binding element 'Person' implicitly has an 'any' type
 subject: string,
 // ~~~~~~ Duplicate identifier 'string'
 // Binding element 'string' implicitly has an 'any' type
 body: string}
 // ~~~~~~ Duplicate identifier 'string'
 // Binding element 'string' implicitly has an 'any' type
) { /* ... */ }

这个 TypeScript 错误是因为函数的参数声明没有指定它们的类型。在函数的参数列表中,没有使用正确的类型注释或类型定义来指定 personsubjectbody 参数的类型。

要修复这个错误,可以为每个参数添加类型注释,例如:

function email({
      person,
      subject,
      body
}: {
      person: Person,
      subject: string,
      body: string
}) {
  // ...
}

或者,可以使用 TypeScript 的类型别名来定义一个新类型,它包含函数参数的类型注释,然后将其用作函数参数类型注释。例如:

type EmailParams = {
      person: Person,
      subject: string,
      body: string
};

function email(params: EmailParams) {
  // ...
}

条款9:优先选择类型声明而不是类型断言

  • 优先使用类型声明(:Type)而不是类型断言(as Type),除非你确信你比TypeScript更了解当前变量的类型

  • 必要时,给箭头函数注释返回类型:

    const people = ['alice', 'bob'].map(
    	(name): Person => ({name}) // 注意第一个括号
    )
    
  • 非空类型断言:const el = document.getElementById('foo')!;

  • 类型断言只能在以下情况下使用:其中一个类型是另一个类型的子类型。当然可以用 unknown 绕开这个限制。

    interface Person { name: string };
    const body = document.body;
    const el = body as Person; // ERROR
    
    const el = body as unknown as Person; // Fine
    

条款10:避免对象包装类(String,Number,Boolean,Symbol,BigInt)

包装类的作用

  1. 提供了一些使用的方法。
  2. 当我们使用基本类型值调用某个方法时,JavaScript会将该值自动转换为相应的包装对象。这种自动转换称为“自动装箱”。例如,当我们使用字符串字面量调用某个方法时,JavaScript会将该字符串自动转换为String对象,以便我们可以调用String类的方法。

包装类带来了一个奇怪的现象:在JavaScript中,基本数据类型是指字符串、数字、布尔值、null和undefined这五种数据类型。这些类型的值是不可变的,也就是说,不能给它们分配属性或方法。如果尝试给基本数据类型分配一个属性,JavaScript会自动将其转换成对应的包装对象,在包装对象上设置属性或方法,并在操作完成后将包装对象丢弃。

坚持使用基本数据类型

基本数据类型可以赋值给包装类型,反之不行。

最后,调用 BigIntSymbol 时不可以用 new,因为它们创建的是基本类型:

typeof BigInt(1234) // "bigint"
typeof Symbol('hh') // "symbol"

条款11:认识额外属性检查的局限性

以下情况下会触发一个叫做额外属性检查(或者严格对象字面量检查)的机制:当把一个对象字面量赋值给一个变量,或者把它作为一个参数传递给一个函数时。

额外属性检查是有局限性的,当使用一个中间变量存储或者使用类型断言,就会被关闭。

如果想禁止这种行为,可以使用 索引签名 告诉TS期待额外的属性:

interface Options {
    darkMode?: boolean;
    [otherOptions: string]: unknow;
}

Type ‘unknown’ is not assignable to any type(except any), any type is assignable to type ‘unknown’

条款12:尽可能将类型应用于整个函数表达式

尽可能将类型应用于整个函数表达式,即使用函数语句而不是函数表达式。

如果你在开发一个类库,尽量为常见的回调函数提供类型声明。在使用一个库的时候尽量使用作者提供的函数类型声明。

有时候我们想包装一个函数,这时可以用 typeof fn 来获取原函数的签名:例如对于 fetch 函数,错误的响应并不会导致一个拒绝的Promise,但我们可以优化这一行为:

const checkedFetch: typeof fetch = async (input, init) => {
    const response = await fetch(input, init);
    if (!response.ok) {
        throw new Error('Network response was not ok: ' + response.status);
    }
    return response;
};

条款 13:了解类型(type)和接口(interface)的区别

typeinterface 的界限越来越模糊,通常情况下,两者均可。

在类型和接口名称前加 TI 并不认为是一种良好的风格,标准库里也没有遵循这一约定。

都可以定义函数类型,不同场合下各有优劣:

type TFn1 = (x: number) => string;

interface IFn1 {
    (x: number): string;
}


type TFn2 = {
    (x: number): string;
    prop: string;
}

interface IFn2 {
    (x: number): string;
    prop: string;
}

可以互相扩展:

interface IStateWithPop extends Tstate { .. };
type TStateWithPop - IState & {...;};

interface 不能扩展复杂的 type 如联合类型,得用 type&(Input | Output) & { name: string }

有联合 type 但没有联合 interface

一般来说 type 的能力更强。

type 可以更容易的表达元组和数组类型:

type Pair = [number, number];
type StringList = string[];
type NamedNums = [string, ...number[]];

// 类似,但放弃了所有元组方法,如 concat
interface Tuple {
    0: number;
    1: number;
    length: 2;
}

interface 可以 扩增type 不行,扩增即接口的声明合并(declaration merging)

// declaration merging

interface IState {
    name: string;
    capital: string;
}

interface IState {
    population: number;
}

// IState; // { name: string; capital: string; population: number; }

TS 通过声明合并实现了不同ES版本切换,如 lib.es5.d.tslib.es6.d.ts 中的 PromiseArray 的声明合并。

当然,如果你想让你的类型无法被扩展,可以使用 type

总结:如果需要一些复杂的类型,使用 type,如果需要扩展,使用 interface(内部使用的类型往往没有扩展的需求)。其他情况下,遵循你的团队的规范。

条款 14:使用类型操作和泛型来避免重复自己的工作

DRY 原则:不要重复自己。

请使用接口扩展消除重复:

interface Person {
    firstName: string;
    lastName: string;
}

interface PersonWithBirthDate extends Person {
    birth: Date;
}

也可以用 & 扩展一个已有类型:type PersonWithBirthDate = Person & { birth: Date };

另一种场景,从一个对象中提取一些属性,或者从一个对象中排除一些属性,可以使用 Pick, Omit

type PersonWithBirthDate = Pick<Person, 'firstName' | 'lastName'> & { birth: Date };
type PersonWithoutBirthDate = Omit<Person, 'birth'>;


type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
// type Exclude<T, U> = T extends U ? never : T;
// type Extract<T, U> = T extends U ? T : never;

也可以手写:

type PersonWithBirthDate = {
    [P in 'firstName' | 'lastName']: Person[P];
} & { birth: Date };

标签联合类型的标签类型:

type Person = {
    type: 'person';
    firstName: string;
    lastName: string;
};

type Animal = {
    type: 'animal';
    name: string;
};

type PersonOrAnimal = Person | Animal;
type PersonOrAnimalType = PersonOrAnimal['type']; // 'person' | 'animal'
// using Pick
type PersonOrAnimalType2 = Pick<PersonOrAnimal, 'type'>['type']; // 'person' | 'animal'

将所有属性变为可选,一个场合:将构造函数参数类型复用于更新函数:

type Partial<T> = {
    [P in keyof T]?: T[P];
};

type Person = {
    firstName: string;
    lastName: string;
};

type PartialPerson = Partial<Person>;

基于一个值,定义类型相同的类型:

const INITIAL_STATE = {
    name: 'Alabama',
    capital: 'Montgomery',
    population: 4874747,
};

type State = typeof INITIAL_STATE;

基于一个函数,定义返回值类型相同的类型:

function getState() {
    return {
        name: 'Alabama',
        capital: 'Montgomery',
        population: 4874747,
    };
}

type State = ReturnType<typeof getState>;
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;

泛型相当于类型的函数,可以通过 extends 约束泛型,这里的 extends 可以理解为是… 的子类型,如 T extends U 可以理解为 T is a subtype of U

type State = {
    name: string;
    capital: string;
    population: number;
};

type T<K extends State> = [K, K];

const t: T<State> = [
    {
        name: 'Alabama',
        capital: 'Montgomery',
        population: 4874747,
    },
    {
        name: 'Alabama',
        capital: 'Montgomery',
        population: 4874747,
    },
];


const t2: T<{ name: string }> = [
    {
        name: 'Alabama',
    },
    {
        name: 'Alabama',
    },
]; // error

注意下面这个例子:

type Pick<T, K> = {
    [k in K]: T[k]
    // ~ Type 'K' is not assignable to type 'string | number | symbol'
};

type Pick<T, K extends keyof T> = {
    [k in K]: T[k] // ok
}

条款 15:为动态数据使用索引签名

类型可以是 sting number symbol 或组合,但一般只需要使用 string

type MyJSON = {
  [key: string]: string | number | boolean | MyJSON | MyJSON[]
}

条款 16:优先选择 Array、Tuple和ArrayLike,而不是数字索引签名

  • 数组是对象,键是字符串,而不是数字。number作为索引签名纯粹是TS的结构,用来捕捉错误
  • 遍历数组不要用 for-in,可以用 for-offorEach(此时i的类型为正确的number)或者 for(;;)
  • 比起使用数字索引签名,优先尝试 Array tupleArrayLike

条款 17:使用 readonly 值避免值变(Mutation)相关的错误