TypeScript

A. Get Start

开始

TypeScript 是一款微软开发的,有着静态类型系统的编程语言。

TypeScript 提供了 JavaScript 的全部特性,除此之外,又为其添加了一套类型系统。

静态类型检查

TypeScript 提供了一套静态类型检查,秉持着能发现错误就及早发现错误的原则,静态类型检查会在真正运行之前检查我们的类型错误,并抛出对应的 TypeError

  
const message = "hello"

message() //This expression is not callable. Type 'String' has no call signatures.

非异常错误

除了运行错误,TS 还会限制一些额外的错误,这些错误在 JavaScript 中并不是错误,但是在 TS 的规范中,也会抛出错误,比如读取对象上不存在的属性:

  
const user = {
  name: 'Jobs',
  age: 56,
}

user.location // Property 'location' does not exist on type '{ name: string; age: number; }'.

这虽然会限制 TS 的语言能力,但是有效的防止了很多意外错误,比如拼写错误或基本逻辑错误。

类型提示

和静态检查一样很好用的功能是类型提示。

TS 提供了强大的类型提示,以第一时间避免我们犯错。

TSC 编译器

当我们安装 typescript 这个依赖时,就安装好了 tsc 编译器。当然我们也可以直接全局安装 TypeScript:

  
npm install -g typescript

然后我们可以直接使用 tsc 编译器来编译一个 TS 文件:

  
tsc some-ts-file.ts

但其实开发时,我们基本不用 tsc 来直接编译,一是 babel 也有一个相同的编译器,二是我们有打包工具,已经配置好工具链了。

抛出错误

tsc 编译器默认即使发生错误,也会进行打包。如果想产生错误时不打包,可以使用 noEmitOnError 配置项:

  
tsc --noEmitOnError some-ts-file.ts

B. HandBook

基础类型-上

  1. 值空间和类型空间

这段 Handbook 上没有直接说,但是我觉得应该写在前面。

在 TS 中,有着两个声明空间,分别是类型声明空间和变量声明空间。

类型声明空间包含着所有的类型,可以用这些类型作为变量的类型注解。变量声明空间含可用作变量的内容,这些会运行在 JavaScript 中。因此我们不能把一些如 interface 定义的内容当作变量使用,类型也是不会影响运行时的

变量空间中,可以通过 typeof 关键字来取出一个变量的类型。

变量空间是真正的运行时,类型空间是 TS 的类型检查,会在编译时被擦除掉。

1. stringnumberboolean

JS 有三个基本值类型:字符串、数字、布尔值。在 TS 中,这些值的类型如标题所示(注意和 JS 中的构造函数区别,这些都是小写的)。

  
let string = 'Hello World!'
let number = 42
let boolean = true

2. 数组

在 TS 中,数组是固定类型的。如果希望数组中有不同类型的成员,可以去看看「元组」或者 any[]

有两种方式可以声明一个数组类型的变量,一是使用 Array 泛型,二是直接声明:

  
let arr1: Array<number> // 使用 Array 泛型,一般不选择这么写
let arr2: number[] // 直接声明

很简单,唯一需要注意的是这里的 Array 不是 JS 中的 Array 构造函数,这个是它的类型,位于类型空间,对运行时来说并不是实体的。

[!note] 类型注解

这里使用的 let arr: number[] 的语法叫做类型注解,用于显示的声明一个变量的类型(一般 TS 可以直接推断出变量类型,不需要显示的添加类型注解)。

3. any

在 TypeScript 中,any 类型意味着取消对一个变量的类型检查。这个变量会和 JS 中的表现一样。

  
let a: any = 123
a = 'hello'
a = { foo: 'foo' }
a.bar()

同时,any 类型可以赋值给其他类型的变量而不报错:

  
let a: any = 'hello'
let num: number = 123

num = a // 不报错

所以,我们要慎用 any

[!note] 隐式的 any

当我们不指定一个变量的类型,并且 TS 无法根据上下文推断出变量类型时,TS 会将这个变量的类型默认为 any,这也是隐式的 any 的来源。

TS 配置文件中的 noImplicitAny 配置项,当 strict 模式开启后,默认是开启的。这个选项会阻止我们的隐式的 any(会报错)。

4. 函数

函数是 JS 中重要的数据传递方式。TS 允许我们显示的指定参数和返回值的类型。

参数类型

参数类型通过类型注解添加:

  
function sum(a: number, b: number) {
  return a + b
}

const sub = (a: number, b: number) => a - b

这个例子中,虽然我们只指定了参数的类型,但是其实返回值的类型也是确定了的,因为 TS 默认推断出了我们做的运算返回值也是 number 类型。

显示声明参数类型的好处是,当我们在其他地方调用这个函数时,如果参数类型传错了,我们可以直接发现问题。

返回值类型

TS 也可以指定返回值类型,此时类型注解需要跟在参数列表后面:

  
function getNumber(): number {
  return 42
}

const getString = (): string => "Hello!"

当我们指定了返回值类型时,TS 会检查我们函数的返回值是否匹配,如果我们的函数的返回值不匹配,或者某个分支的返回值不匹配时,TS 会抛出错误。

函数类型和高阶函数

由于 JS 是一个函数优先的编程语言,所以高阶函数很常见,对于高阶函数的类型,首先我们需要知道函数本身的类型。

假如我们有一个 sum 函数,那么他的类型是:

  
// sum.ts
function sum(a: number, b: number) {
  return a + b
}
  
// sum.d.ts
declare function sum(a: number, b: number): number

而在高阶函数中,如果我们接受一个函数作为参数:

  
function addOneAndTwo(sumFunc: (a: number, b: number) => number) {
  const result = sumFunc(1, 2)
  return result
}

这里的函数类型,由一个参数列表 (a: number, b: number),一个箭头 => 和返回值类型 number 组成。

当一个高阶函数声明了参数类型时,我们再传递匿名函数就不需要显示指定参数类型了,可以想想平时在 TS 里面用的 Array.prototype.forEach 这种高阶函数。

this 参数

1. 普通方法调用

如果直接在对象的方法中使用 this,TS 可以直接推断出来:

  
const obj = {
  name: 'Jobs',
  getName() {
    return this.name // 没有报错
  }
}

如果使用了箭头函数,那么肯定是会报错的,因为 this 指向了 window

  
const obj = {
  name: 'Jobs',
  getName: () => {
    // The containing arrow function captures the global value of 'this'.
    // Property 'name' does not exist on type 'typeof globalThis'.
    return this.name
  }
}

但是如果我们将它解构出来(或者直接赋值出来,反正就是不通过 obj 调用函数),TS 是检测不出来这个错误的:

  
const obj = {
  name: 'Jobs',
  getName() {
    return this.name // 没有报错
  }
}

const { getName } = obj
const n = getName() // TS 认为没有错,推断出来 n 是 string 类型

对于这种情况,我们可以为对象添加一个类型注解,来显示的声明函数的 this 类型。

  
type Obj =  { // 对象类型,后面再详细解释
  name: string;
  getName(this: Obj): string;
}

const obj: Obj = {
  name: 'Jobs',
  getName() {
    return this.name
  }
}

const { getName } = obj
const n = getName() // The 'this' context of type 'void' is not assignable to method's 'this' of type 'Obj'.

2. 构造函数中的 this

在 TS 中,我们很少会用函数作为构造函数的形式(直接用 class),原因很简单,一方面 class 很好用,一方面 TS 对这块的类型推断真的不太行。

(在这里我想写个例子,然后发现实在是太费劲了,直接用 class 吧)

在 class 中,使用 this 不需要显示声明:

  
class People {
  name: string
  constructor(name: string) {
    this.name = name
  }
  getName() {
    return this.name
  }
}

const people = new People('Jobs')

5. 对象类型

对象的类型和真正的对象定义类似,但是不包含属性值,而是属性类型:

  
type People = {
  name: string;
  age: number;
  getName(): string;
}

const people: People = {
  name: 'Jobs',
  age: 56,
  getName() {
    return this.name
  }
}

属性类型后面的分号可有可无。

如果是作为函数参数,也是一样的:

  
function pointToString(point: { x: number, y: number }) {
  return `x: ${point.x}, y: ${point.y}`
}

const p = { x: 1, y: 2 }

const pStr = pointToString(p)
可选属性

如题,我们需要某个属性可有可无,和我一样,真可悲。

  
type Name = {
  first: string;
  last?: string;
}

const name: Name = {
  first: 'Steven'
}

const fullName: Name = {
  first: 'Steven',
  last: 'Jobs'
}

基础类型-中

1. 联合类型

TypeScript 的类型系统允许我们使用已有类型构造新的类型。首先是联合类型(Union Type),联合类型由多个类型组成,表示当前的值可以为这些值中的一个。

  
function printId(id: string | number) {
  console.log('Your id is: ' + id)
}

printId('123')
printId(456)
printId(true) // Argument of type 'boolean' is not assignable to parameter of type 'string | number'.

在处理联合类型时,我们需要使用类型收窄(Narrowing,其实就是分支判断):

  
function printId(id: string | number) {
  if (typeof id === 'string') {
    // 在这个分支中,id 一定为 string
    console.log(id.toUpperCase())
  } else {
    // 在这个分支中,id 一定为 number
    console.log(id);
  }
}

这里面用到的 typeof,是值世界里的 typeof,不能使用类型世界的判断来进行类型收窄。常见的类型收窄有:

  • typeof
  • Array.isArray()
  • instanceof
  • in

对于联合类型,收窄后的类型是原先类型的子类型:

  
type T = string extends string | number ? 'A' : 'B'

const check: T = 'B' // Type '"B"' is not assignable to type '"A"'.
// 说明 T 是 'A',进一步说明 string 是 string | number 的子类型

其实仔细思考,对于类型而言,子类型相较于父类型应该是更具体的,所以更具体的 string 类型是 更宽泛的 string | number 的子类型,没有问题。

2. 类型别名

这节介绍的是使用蛮多的 type 关键字,使用 type 可以定义一个类型别名。

  
type Point = {
  x: number;
  y: number;
}

function printPoint(p: Point) {
  console.log("x is: " + p.x);
  console.log("y is: " + p.y);
}

printPoint({ x: 100, y: 100 });

也可以使用联合类型来指定别名:

  
type ID = string | number

要注意的是类型别名只是别名,他们不是一个全新的类型:

  
type MyString = string

let foo: MyString = 'Hello'
let bar: string = 'World'

foo = bar // 完全可以

3. 接口

接口也是极其常用的功能。接口声明的是一个对象的类型。

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

function printPoint(p: Point) {
  console.log("x is: " + p.x);
  console.log("y is: " + p.y);
}

printPoint({ x: 100, y: 100 });

和类型别名的使用是一样的,接口只负责检查对象的类型,确保类型和接口的声明是一致的。

接口可以继承另一个接口,这会在其上拓展新的类型:

  
interface People {
  name: string
  age: number
}

interface Student extends People {
  stuId: string
}

const student: Student = {
  name: 'Siven',
  age: 18,
  stuId: '123456'
}
接口和类型别名的区别
interface type
只能用于声明对象的形状 可以对所有类型重命名
可以继承父接口 可以使用 & 操作符(交叉类型)来实现继承
多次声明同一个接口可以将所有声明合并 不能多次声明同一个类型别名

4. 类型断言

有时 TypeScript 并不知道一个变量到底是什么类型,需要我们指定。

一个很常见的例子就是当我们获取 DOM 时,TS 自然不知道这个元素是什么类型的,这时我们可以使用 as 来明确的对这个变量的类型进行断言:

  
const myCanvas = document.getElementById("main_canvas") as HTMLCanvasElement;  

当然,也可以使用下面这种语法,但是非常不推荐,这种形式很容易与 JSX 等语法混淆:

  
const myCanvas = <HTMLCanvasElement>document.getElementById("main_canvas")

[!note] 限制 类型断言只允许将某个值断言为更具体或者是更不具体的值,不可以将无关的类型断言,比如 const x = 'hello' as number。 如果确实需要强制转换,可以考虑先转为 unknownany,再转换为具体值。

5. 字面量类型

当我们使用 let 声明时,TS 默认会将一个变量推断为基本类型,比如下面这个例子:

  
let a = 'hello' // a 被推断为 string 类型

const b = 'hello' // b 被推断为 'hello' 字面量类型

我们可以使用字面量的联合,来实现更好的限制:

  
function move(direction: 'up' | 'down' | 'left' | 'right') {
  // ...
}

move('other') // Argument of type '"other"' is not assignable to parameter of type '"up" | "down" | "left" | "right"'.

如果我们想使用 let 进行推断,同时将其类型收窄为字面量,可以使用 as const

  
let a = 'hello' as const // a 被推断为 'hello' 字面量

对于对象,字面量也是一样,但是其中的属性不会因为使用 const 声明而被推断为字面量:

  
const obj = {
  count: 1
} // obj 类型为 { count: number; }

obj.count = 2 // 完全可以

同样,使用 as const 可以限制属性为字面量且只读:

  
const obj = {
  count: 1
} as const // obj 类型为 { readonly count: 1; }

obj.count = 2 // Cannot assign to 'count' because it is a read-only property.

6. nullundefined

JavaScript 中有两个原始值,用于表示缺失或未初始化的值:nullundefined。在 TS 中,也有两个类型表示这两个值。

可选类型实际上就是被联合了 undefined 类型:

  
function foo(a?: string) {
  if (typeof a === 'undefined') {
    a // undefined 类型
  } else {
    a // string 类型
  }
}
非空断言

TypeScript 还有一个特殊的语法,可以在不做任何显式检查的情况下从类型中删除 nullundefined。在任何表达式后面添加 ! 以表示该值不是空或未定义。

  
function foo(x?: string) {
  console.log(x!.toUpperCase())
}

基础类型-下

1. void

void 表示函数没有返回值,但其实在 JavaScript 中,没有返回值就会默认返回 undefined。但是在 TS 中,voidundefined 并不相同。

  
// 这个函数的返回值就是 void。当然,不写 return 语句也是一样的
function noop() {
  return;
}

2. object

这个值表示所有的引用类型,即非原始值(非 stringnumberbigintbooleansymbol、 nullundefined)。

注意,这个值并不是空对象类型 { },也并不是 JS 的 Object 类。

函数也是 object 类型。

在 TS 中,我们应该一直使用 object 而不是 Object 类。

3. unknown

unknown 表示任意值。和 any 类似,但是这个值更安全,用 unknown 做任何事都是不允许的。

  
function f1(a: any) {
  a.b(); // OK
}

function f2(a: unknown) {
  a.b(); // 'a' is of type 'unknown'.
}

我们可以进一步对 unknown 类型进行收窄,来判断他是否能被调用(也可以收窄为其他类型):

  
function f2(a: unknown) {
  if (typeof a === 'function') {
    a()
  }
}

很多时候我们希望使用 any 时,都要慎重考虑能不能使用 unknown 来代替。

4. never

never 比较特殊,他并不是一个有实际值的类型,表示「无法达到的类型」或者直接叫「函数永远没有返回值」。

那么什么时候函数没有返回值呢?一个没有 return 语句的函数并不能算是,之前我们介绍了 void。函数没有返回值是因为函数抛出了错误。

  
function fail(msg: string): never {
  throw new Error(msg)
}

never 还有一种场景很常见,那就是无法达到的分支:

  
function foo(x: string | number) {
  if (typeof x === 'string') {
    // ...
  } else if (typeof x === 'number') {
    // ...
  } else {
    x // never 类型
  }
}

这里的“无法达到的分支”,只是在 TS 的静态类型检查中无法达到,如果用某些手段逃开了 TS 的类型检查,在 JS 运行时肯定还是可以达到的。所以我们要更加注意代码的编写规范。

5. Function

这个类其实就是 JavaScript 中的 Function 构造函数对应的类型。但是一个 Function 类型的变量在调用后,返回的类型是 any

  
function foo(fn: Function) {
  fn(1, 2, 3)
}

这是无类型的函数调用,我们应该尽量避免,因为返回的 any 类型很不安全。

类型收窄

类型收窄在之前有介绍过,这节详细介绍。

假设有一个工具函数,可以在字符串的左侧进行填充。如果第一个参数为数字,则会填充指定个数的空格;而如果第一个参数是字符串,则会在左侧填充这个字符串:

  
function padLeft(padding: number | string, input: string) {
  return " ".repeat(padding) + input //Argument of type 'string | number' is not assignable to parameter of type 'number'. Type 'string' is not assignable to type 'number'.
}

这时我们得到提示,我们没有在逻辑内对 padding 参数做校验,我们可以对其进行类型收窄:

  
function padLeft(padding: number | string, input: string) {
  if (typeof padding === 'number') {
    return " ".repeat(padding) + input
  }
  return padding + input
}

虽然看起来平平无奇,但是实际上 TypeScript 在这里将静态类型分析覆盖在了 JavaScript 运行时控制流程上,比如 if/else、三元运算符、循环、真值检查等。

在 if 检查中,typeof padding === "number" 这段代码叫做「类型保护」。

使用 typeof 运算符,我们可能得到以下结果:

  • "string"
  • "number"
  • "bigint"
  • "boolean"
  • "symbol"
  • "undefined"
  • "object"
  • "function"

真值收窄

在开发时,我们通常会使用真值来判断一个变量是否有值。比如下面这个例子,我们使用 && 判断了 strs 是否为 null

  
function printAll(strs: string | string[] | null) {
  if (strs && typeof strs === 'object') {
    for (const s of strs) {
      console.log(s)
    }
  } else if (typeof strs === 'string') {
    console.log(strs)
  }
}

in 操作符收窄

可以使用 in 操作符检查某个变量是否存在一个属性,来对类型进行收窄:

  
type Fish = { swim: () => void }
type Bird = { fly: () => void }

function move(animal: Fish | Bird) {
  if ('swim' in animal) {
    return animal.swim()
  }

  return animal.fly()
}

使用类型谓词

我们还可以使用返回值为类型谓词的函数,来鉴别类型,这在 JavaScript 运行时同样有效。

  
function isFish(animal: Fish | Bird): animal is Fish {
  return (animal as Fish).swim !== undefined
}

const animal = getAnimal()

if (isFish(animal)) {
  animal.swim()
} else {
  animal.fly()
}

is 谓词只能用于这种检查函数的返回值,不可以作为变量的类型注解。

深入函数类型.

函数类型表达式

这一块在之前也介绍过,用于描述一个函数本身的类型,通常在高阶函数中使用:

  
function greeter(fun: (a: string) => void) {
  fn("hello world")
}

function printToConsole(s: string) {
  console.log(s)
}

greeter(printToConsole)

(a: string) => void 语法描述了“一个具有一个 string 类型参数的函数,返回 void 类型(即没有返回值)”。和函数声明一样,当参数类型没有指定时,默认是 any 类型。

函数的参数名是必须的,虽然它没有用。

可调用签名

在 JavaScript 中,函数的本质是一个可调用的对象。当我们从对象的角度描述一个函数类型时,可以使用可调用签名,来表示这个对象可以被调用:

  
type DiscribableFunction = {
  description: string;
  (someArg: number): boolean;
}

function doSomething(fn: DiscribableFunction) {
  console.log(fn.description + " returned " + fn(6));
}

function myFunc(someArg: number) {
  return someArg > 3
}

myFunc.description = "default description";

doSomething(myFunc);

注意这里的参数列表和返回值类型之间是 : 而不是 =>

构造器签名

在 JavaScript 中,函数可以被 new 操作符执行,这会构造出一个对象。如果从对象的角度看,一个函数可被构造,那么可以使用构造器签名来标识:

  
type SomeConstructor = {
  new (s: string): SomeObject;
}

function fn(ctor: SomeConstructor) {
  return new ctor("hello");
}

如果一个函数即可被构造,又可以被调用,这两个签名可以同时存在:

  
interface CallOrConstruct {
  new (s: string): Date;
  (n?: number): string;
}

函数泛型

使用函数泛型,可以写出一些通用函数,将函数类型检查推迟到调用时。

假如我们有一个取数组第一个元素的函数,它不会关心数组是什么类型的:

  
function getFirstElement<T>(arr: T[]): T | undefined{
  return arr[0]
}

const strArr = ['Jobs', 'Siven']
const numArr = [56, 18]

getFirstElement(strArr)
getFirstElement(numArr)

上面这种形式,是我们通过 TS 的类型推断来实现了泛型调用,我们也可以明确的在调用时声明使用了什么泛型:

  
getFirstElement<string>(strArr)

一般来说,我们都不需要明确的指定使用的泛型,TS 的推断十分强大:

  
function map<I, O>(arr: I[], fn: (arg: I) => O): O[] {
  return arr.map(fn)
}

// n 被推断为 string 类型
// parsed 被推断为 number[] 类型
const parsed = map(["1", "2", "3"], (n) => parseInt(n));
泛型约束

当我们希望限制调用者使用的泛型类型时,可以使用 extends 来限制。下面这个函数接受两个类数组或数组,返回两个数组中较长的那个:

  
function longest<T extends { length: number }>(a: T, b: T) {
  return a.length >= b.length
    ? a
    : b
}

const longerArray = longest([1, 2], [1, 2, 3]);
const longerString = longest("alice", "bob");
const notOK = longest(10, 100); // Argument of type 'number' is not assignable to parameter of type '{ length: number; }'.

这个例子中,TS 直接推断出了我们返回值的类型。

可选参数

JavaScript 中函数的参数个数可以不固定。如果有些参数是可选的,可以将其声明为可选参数:

  
function foo(a: number, b?: number) {
  // ...
}

foo(1)
foo(1, 2)

如果参数需要默认值,也可以直接声明:

  
function foo(a: number, b = 3) {
  // ...
}

foo(1)
foo(1, 2)

在高阶函数中,实际上我们很多时候不需要可选参数,比如这个自己实现的 forEach 函数,如果我们将回调函数的部分参数声明为可选参数:

  
function myForEach<T>(arr: T[], cb: (item: T, index?: number, arr?: T[]) => void) {
  for (let i = 0; i < arr.length; i++) {
    cb(arr[i], i, arr)
  }
}

myForEach([1, 2, 3], (item, index, arr) => {
  console.log(index.toFixed()) // 'index' is possibly 'undefined'.
})

在 JS 中,多余的参数会被忽略,在 TS 中也一样。对于高阶函数,我们可以只传入更少的函数。所以我们不应该使用可选参数。

  
function myForEach<T>(arr: T[], cb: (item: T, index: number, arr: T[]) => void) {
  for (let i = 0; i < arr.length; i++) {
    cb(arr[i], i, arr)
  }
}

myForEach([1, 2, 3], (item, index, arr) => {
  console.log(index.toFixed()) // 完全可以
})

函数重载

TS 的函数重载融合了很多思想,他是一种介于静态类型语言和动态类型语言之间的平衡点,你也可以叫他「函数重载-JS 妥协版」。

函数重载可以通过多次声明,来指定函数的参数和返回值,但是在具体实现上,还是依赖 JS 进行参数校验。

下面这个例子中,我们实现了两种创建 Date 对象的方式,一是传入一个时间戳,二是传入三个参数,表示年月日:

  
function makeDate(timestamp: number): Date;
function makeDate(m: number, d: number, y: number): Date;

function makeDate(mOrTimestamp: number, d?: number, y?: number): Date {
  if (d && y) {
    // 实现三个参数的重载
    return new Date(y, mOrTimestamp, d)
  }
  // 实现一个参数的重载
  return new Date(mOrTimestamp)
}

const d1 = makeDate(12345678);
const d2 = makeDate(5, 5, 5);
const d3 = makeDate(1, 3); // No overload expects 2 arguments, but overloads do exist that expect either 1 or 3 arguments.

可以看到,我们可以使用一个参数,或三个参数来调用,但是不能使用两个参数。

一个常见的问题是在函数重载签名中声明了参数,但是在实现上却没有接收对应的参数:

  
function foo(): void
function foo(x: string): void
function foo() {
  // ...
}

foo()

这样做虽然不会引起 TS 报错,但是要记住的是,所有函数的重载签名最后都会被擦除,我们需要首先保证 JS 的逻辑性,才能去谈 TS 的类型检查和提示。在这个例子中,无论重载声明了多少个参数,具体实现上却没有接收对应的参数,自然无法完成相应的逻辑(如果你的逻辑不需要这个参数,你也不需要去声明它了。既然声明了参数 x,就说明你的逻辑需要它,但是在实现上却没有接收它)。

TypeScript 中的函数重载没有任何运行时开销。它只允许你记录希望调用函数的方式,并且编译器会检查其余代码。

正确的做法应该是找出你参数最多的签名和最少的签名,然后将这些多出来的参数声明为可选参数:

  
function foo(): void
function foo(x: string): void
function foo(x?: string) {
  // ...
}

foo()

剩余参数

和 JS 一样,我们可以将剩余参数收集到一个数组。不同的是,在 TS 中我们需要为其添加类型注解:

  
function multiply(n: number, ...m: number[]) {
    return m.map((x) => n * x)
}

参数解构

另一个在 ES6 中常用的语法是解构,我们可以直接在函数的参数上解构:

  
function sum({a, b, c}) {
  return a + b + c
}

但是在 TS 中,我们需要添加对应的类型注解:

  
function sum({a, b, c}: { a: number, b: number, c: number }) {
  return a + b + c
}

如果你觉得上面的代码太过冗余,可以将类型声明部分抽出去:

  
type ABC = {
  a: number
  b: number
  c: number
}

function sum({a, b, c}: ABC) {
  return a + b + c
}

对象类型

属性修饰符

在 TS 中,对象的每个属性都可以指定以下的功能:属性类型、是否可选、是否只读。

可选属性

这个很简单:

  
interface PaintOptions {
  shape: Shape;
  xPos?: number;
  yPos?: number;
}

这样,这其中的 xPosyPos 属性就都被设置为可选属性了。当我们读取可选对象时,TS 会将其类型联合 undefined,以表示这个属性可能为空:

  
function paintShape(opt: PaintOptions) {
  let xPos = opt.xPos // xPos 为 number | undefined 类型
}

通常我们可以使用三元表达式为其补上默认值:

  
function paintShape(opt: PaintOptions) {
  let xPos = opt.xPos ? opt.xPos : 0
}

或者可以直接在参数列表中解构对象,并补充默认值:

  
function paintShape({shape, xPos = 0, yPos = 0}: PaintOptions) {
  xPos // xPos 为 number 类型,这里就没有 opt 对象了,因为被解构掉了
}

移除可选属性: (你不是想看点神奇的吗? 我们可以移除一个对象类型的只读属性:

  
type OptionalPerson = {
  name: string
  age?: number
  address?: string
}

type Concrete<T> = {
  // 使用 - 来移除某个修饰符(具体看映射类型一章)
  // 使用 in 来遍历联合属性
  // 使用 keyof 来访问对象的键
  [k in keyof T]-?: T[k]
}

type Person = Concrete<OptionalPerson> // 每个属性都是不可选的
/**
Person = {
  name: string
  age: number
  address: string
}
*/
只读属性

只读属性通过 readonly 关键字构造:

  
interface SomeType {
  readonly prop: string
}

function doSomething(obj: SomeType) {
  obj.prop = 'hello' // Cannot assign to 'prop' because it is a read-only property.
}

当试图修改只读属性时,TS 会报错,阻止修改。

readonly 修饰符只能保证属性的引用值不被修改,不能保证其中的属性被修改,类似 const 和 JS 对象的逻辑:

  
interface Home {
  readonly resident: {
    name: string
    age: number
  }
}

function foo(home: Home) {
  home.resident.age += 1 // 完全可以
}

function bar(home: Home) {
  home.resident = { // Cannot assign to 'resident' because it is a read-only property.
    name: 'Jobs',
    age: 56
  }
}

移除只读属性: 和上一节类似,我们可以使用 - 修饰符移除只读修饰符:

  
type LockedAccount = {
  readonly id: string
  readonly name: string
}

type CreateMutable<T> = {
  -readonly [k in keyof T]: T[k]
}

type UnlockedAccount = CreateMutable<LockedAccount>
/**
UnlockedAccount = {
  id: string
  name: string
}
*/

索引签名

如果我们不关心键值的具体名字,可以使用索引签名来实现:

  
// 实现一个类数组类型
interface LikeStringArray {
  [index: number]: string
}

const myArr: LikeStringArray = ['Jobs', 'Siven']

const item = myArr[1] // string 类型

索引签名只支持 stringnumbersymbol 类型

索引签名是描述“字典”模式的一种强大方式,但它们也强制要求所有属性与其返回类型匹配。在下面的例子中,name 的类型与字符串索引的类型不匹配,类型检查器给出了一个错误:

  
interface NumberDictionary {
  // 会约束所有属性的值都是 number 类型
  [index: string]: number;

  length: number // OK
  name: string // Property 'name' of type 'string' is not assignable to 'string' index type 'number'.
}

当然,只要把 number 类型换成 number | string 的联合类型就可以解决上面的报错了。

多余属性检查

当一个对象被赋值给另一个对象时,TS 会检查这个对象是否有多余的属性,如果有多余的属性,则会报错:

  
interface SquareConfig {
  color?: string;
  width?: number;
}

function foo(opt: SquareConfig) {
  // ...
}

foo({ colour: "red", width: 100 }) // Argument of type '{ colour: string; width: number; }' is not assignable to parameter of type 'SquareConfig'.

注意,这里我们传入的不是 color,而是 colour。这在 JS 中会静默失败,但是 TS 认为是一个错误,因为这很有可能是无心导致的 bug 来源,而且错误很难排查。

如果想要将这个对象配置为接受任何多余属性,可以使用前一节提到的索引签名:

  
interface SquareConfig {
  color?: string;
  width?: number;
  [key: string]: any; // 接受多个任意类型的多余参数
}

类型继承

为对象扩展类型时很常见的需求。假如我们有一个 BasicAddress 类型的对象,描述了一个地址:

  
interface BasicAddress {
  name?: string;
  street: string;
  city: string;
  country: string;
}

可能我们需要在其上拓展一个 unit 属性,这并不需要我们将之前的属性重写一遍,我们可以直接使用 extends 关键字来继承类型:

  
interface AddressWithUnit extends BasicAddress {
  unit: string
}

extends 支持多继承,你可以将一个对象继承自多个父对象:

  
interface Colorful {
  color: string
}

interface Circle {
  radius: number
}

interface ColorfulCircle extends Colorful, Circle {}

const cc: ColorfulCircle = {
  color: 'red',
  radius: 42
}

当然,这个功能还可以由交叉类型 & 实现:

  
type ColorfulCircle = Colorful & Circle

类型泛型

TS 的对象类型也是支持泛型的,无论是 interface 还是 type

  
interface Box<T> {
  contents: T
}

const box: Box<number> = { contents: 42 }

通过泛型,我们提高了类型的复用性。很多情况下,我们可能会将某些值声明为 anyunknown 类型,这时可以考虑能否使用泛型代替。

泛型甚至还支持嵌套:

  
type OrNull<T> = T | null

type OneOrMany<T> = T | T[]

type OneOrManyOrNull<Type> = OrNull<OneOrMany<Type>>;

元组类型

元组是有限个数的数组,它必须明确的指定数组的元素个数以及元素类型。

  
type Tuple = [string, number]

function foo(pair: Tuple) {
  const c = pair[2] // Tuple type 'Tuple' of length '2' has no element at index '2'.
}

元组类型也可以使用剩余参数,来添加不确定个数的元素:

  
type StringNumberBooleans = [string, number, ...boolean[]];
type StringBooleansNumber = [string, ...boolean[], number];
type BooleansStringNumber = [...boolean[], string, number];
  • StringNumberBooleans 意味着这个元组前两个元素必须是 stringboolean 类型。后面可以有不确定个数的 boolean 类型。
  • StringBooleansNumber 意味着这个元组第一个元素必须是 string 类型,中间可以有不确定个数的 boolean 类型,最后一个则必须是 number 类型。
  • BooleansStringNumber 意味着这个元组开头可以有不确定个数的 boolean 类型,最后两个元素必须是 stringboolean 类型。

如果设置了剩余参数,那么元组就没有了确定个长度了。

这个功能也可以用在参数列表上:

  
function foo(...args: [string, number, ...boolean[]]) {
  const [str, num, ...bools] = args
  // ...
}

但是以上写法又可以有更简洁的写法:

  
function foo(str: string, num: number, ...bools: boolean[]) {
  // ...
}
只读元组

元组可以被 readonly 修饰,称为只读元组:

  
function foo(pair: readonly [string, number]) {
  pair[0] = 'hello' // Cannot assign to '0' because it is a read-only property.
}

泛型

类型泛型

上一章我们介绍了泛型类型。基本使用:

  
function identity<Type>(arg: Type): Type {
  return arg;
}

let myIdentity: <Type>(arg: Type) => Type = identity;

如果想要在签名中使用泛型,可以这样:

  
// 可调用签名
function identity<Type>(arg: Type): Type {
  return arg;
}

let myIdentity: { <Type>(arg: Type): Type } = identity;

// 可构造签名
type Foo = {
  new<T>(): {}
}

const foo: Foo = class {}

箭头函数没办法直接添加泛型,但是我们可以为变量添加可调用的类型注解:

  
const foo: { <T>(): {} } = () => {
  return {}
}

泛型类

在 ES6 中,一个很重要的语法糖是 class,之前的 function 模式实在是太难写了。

泛型类和泛型接口类似:

  
class MyArray<T> extends Array<T> {
  constructor() {
    super()
    return new Array<T>()
  }
}

泛型约束

如果我们不希望使用者可以传递任意类型的泛型,我们可以使用 extends 关键字对其进行约束:

  
function getLen<T extends { length: number }>(arr: T) {
  return arr.length
}

const numLen = getLen([1, 2, 3])
const strLen = getLen('hello')

上面的例子,限制了我们传入的泛型必须包含 length 属性。

在泛型约束中使用类型参数

如果我们想通过其中一个泛型参数,来约束另一个参数,可以使用这个模式。比如我们有一个获取属性的函数:

  
function getProp<T, K extends keyof T>(obj: T, key: K) {
  return obj[key]
}

const obj = {
    name: 'Jobs',
    age: 56,
}

const objName = getProp(obj, 'name')

const err = getProp(obj, 'address') // Argument of type '"address"' is not assignable to parameter of type '"name" | "age"'.

泛型参数的默认值

泛型参数可以有默认值:

  
declare function create<T extends HTMLElement = HTMLDivElement, U = T[]>(
  element?: T,
  children?: U
): Container<T, U>;

泛型参数默认值有以下规则:

  • 如果一个类型参数有默认值,那么它被视为可选的
  • 必需的类型参数不能在可选类型参数之后(必须参数在前面,和 JS 的逻辑一样)
  • 类型参数的默认类型必须满足类型参数的约束(如果存在)
  • 在指定类型参数时,只需要为必需的类型参数指定类型参数。未指定的类型参数将解析为它们的默认类型(有默认值就不需要显示传递了,这句是废话)
  • 如果指定了默认类型但推断不能选择候选项,则将推断默认类型
  • 与现有类或接口声明合并的类或接口声明可以引入现有类型参数的默认值

keyof 操作符

keyof 操作符接受一个对象类型(注意是类型,如果你拿到的是值,考虑使用 typeof 来获取类型),返回该对象类型所有键值的联合类型。

  
type Point = { x: number; y: number }

type PKeys = keyof Point // "x" | "y"

如果对象内有索引类型,那么 keyof 会返回对应的索引类型:

  
type Arrayish = { [n: number]: unknown }

type A = keyof Arrayish // number

type Mapish = { [k: string]: boolean }

type M = keyof Mapish // string | number

注意,后面这个 M 类型为 string | number,是因为 JS 对象的键一定是一个字符串,obj[0]obj['0'] 是一样的。

typeof 操作符

JS 中也有一个 typeof 操作符,是用来获取值的类型的,TS 中的 typeof 则是用来将转换为类型

  
let x = "hello world"
type str = typeof x // string

上面的例子是基础类型,所以看起来没什么用。但是在复杂的引用类型上,typeof 通常很有用。

限制

TS 故意限制了 typeof 的能力,以防止你编写你认为正在执行但实际上没有执行的代码的混乱陷阱:

  
// 其实是希望使用 ReturnType<typeof msgbox>
let shouldContinue: typeof msgbox("Are you sure you want to continue?"); // ',' expected.

索引访问类型

我们可以使用索引访问类型来查看指定属性的类型:

  
type Person = {
  age: number
  name: string
  alive: boolean
}

type Age = Person['age'] // number

type I1 = Person['age' | 'name'] // string | number

type I2 = Person[keyof Person] // string | number | boolean

索引类型也是类型,所以我们可以使用联合类型、keyof 关键字、或者其他类型来访问。如果当前属性不在该对象上,则会进行报错:

  
type I1 = Person["alve"]; // Property 'alve' does not exist on type 'Person'.

另一个使用任意类型进行索引的例子是使用 number 来获取数组元素的类型,和 typeof 结合使用,可以轻易地获取到数组成员的类型:

  
const myArray = [
  { name: "Jobs", age: 56 },
  { name: "Siven", age: 18 },
]
// 实际上应该是(typeof myArray)[number],小括号被省略了。
type Person = typeof myArray[number]

条件类型

很多时候,程序需要根据输入决定类型。在 TS 中,值也可以使用条件判断来指定:

  
interface Animal {
  live(): void;
}

interface Dog extends Animal {
  woof(): void;
}

type Example1 = Dog extends Animal ? number : string; // number

type Example2 = RegExp extends Animal ? number : string; // string

可以看到,条件类型和 JS 中的三元表达式很相近,但是 TS 的条件类型本质还是静态的类型检查,不能像 JS 一样有运行时。

条件类型如果用在确定类型上,其实没太大用处。它真正的使用之处是和泛型搭配:

  
type NameOrId<T extends number | string> = T extends number
  ? IdLabel
  : NameLabel

// string 类型为 name,number 类型为 id
function createLabel<T extends string | number>(idOrName: T): NameOrId<T> {
  // ...
}

let a = createLabel("typescript"); // NameLabel

let b = createLabel(2.8); // IdLabel

let c = createLabel(Math.random() ? "hello" : 42); // NameLabel | IdLabel

除此之外,它还能实现很多工具泛型,比如下面这个获取数组的成员类型的工具:

  
type Flatten<T> = T extends any[] ? T[number] : T

type Str = Flatten<string[]> // string

type Num = Flatten<number> // number

在条件类型中进行推断

通过 infer 关键字,我们可以在条件类型中,推断一个类型。

  
type Flatten<Type> = Type extends Array<infer Item> ? Item : Type;

这一段的官网原话是:「条件类型为我们提供了一种从我们在 true 分支中与之进行比较的类型中推断类型的方法,即使用 infer 关键字。例如,在 Flatten 中,我们可以推断出元素类型,而不是使用索引访问类型“手动”提取它。在这里,我们使用 infer 关键字来声明性地引入一个名为 Item 的新通用类型变量,而不是指定如何在 true 分支中检索 Type 的元素类型。」

个人感觉,infer 的作用其实更像是声明了一个变量,或者是临时声明了一个类型用于指代某个未知的类型。这里我们希望 Type 类型可以继承自任意的 Array 泛型,并在后面有使用这个任意类型,所以我们使用 infer 将其推断为一个“变量”。

infer 的常用操作是提取函数的返回值(也可以使用 ReturnType<> 泛型):

  
type Foo = () => string

type MyReturnType<T extends (...args: any) => any> = T extends () => infer R ? R : never

type Str = MyReturnType<Foo> // string

type Nev = MyReturnType<string> // Type 'string' does not satisfy the constraint '(...args: any) => any'.

在这里,我们实现了一个 ReturnType 泛型,可以直接获取函数类型的返回值。

分配条件类型

当条件类型作用于泛型类型时,如果传入的是联合类型,条件类型会变为分布类型,假如我们有一个条件类型,用于得到一个类型的数组类型:

  
type ToArray<Type> = Type extends any ? Type[] : never;

当我们传入一个联合类型时,条件类型会作用于联合类型中的每一个成员:

  
type ToArray<Type> = Type extends any ? Type[] : never;

type StrArrOrNumArr = ToArray<string | number>; // string[] | number[]

这里我们得到的是 string[] | number[],而不是我们期望的 (string | number)[]

这种默认的行为,叫做「分配类型」,如果想要避免这个默认行为,希望能将联合类型作为一个整体处理,可以将 extends 两边使用方括号括起来,表示它作为一个整体:

  
type ToArrayNonDist<Type> = [Type] extends [any] ? Type[] : never;

ype StrArrOrNumArr = ToArrayNonDist<string | number>; // (string | number)[]

使用分配类型(就是默认的行为),可以实现几个有意思的泛型:

  
// 实现 TS 内置的工具泛型 Exclude
type MyExclude<T, U> = T extends U ? never : T

// 由于分配类型,'a' | 'b' 会依次被处理,如果 T 继承自 U,则会返回 never 类型,而 never 在联合类型中会被消除掉
type B = MyExclude<'a' | 'b', 'a'> // 'b'

Includes 也是同样的道理,可以自己实现看看。这道题在 TS 类型体操中是简单题,题号#898。

映射类型

使用映射类型,我们可以快速的基于已有的类型进行映射操作。比如我们已经有一个 Features 类型,我们希望将其每一个属性都改为 boolean

  
type Features = {
  darkMode: () => void
  newUserProfile: () => void
}

type OptionsFlags<T> = {
  [Key in keyof T]: boolean
}

type FeaturesOptions = OptionsFlags<Features> // { darkMode: boolean; newUserProfile: boolean; }

映射修饰符

在映射时,我们可以添加或减少只读修饰符 readonly 和 可选修饰符 ?。通过 -+ 来对他们进行添加或者减少(不写 + 就是默认添加,所以一般不会写 + 的,都是直接加修饰符)。

  
type OptionalPerson = {
  name: string
  age?: number
  address?: string
}

type Concrete<T> = {
  [k in keyof T]-?: T[k]
}

type Person = Concrete<OptionalPerson> // 每个属性都是不可选的
/**
Person = {
  name: string
  age: number
  address: string
}
*/

这个例子在之前讲过,其实当时是超纲的,现在是世界线的收束。

通过 as 对键值重新映射

如果我们不仅希望能对值或修饰符修改,TS 4.1 推出了键值重映射,我们可以对键值进行统一修改:

  
type Getters<T> = {
  [Key in keyof T as `get${Capitalize<string & Key>}`]: () => T[Key]
}

interface Person {
  name: string
  age: number
}

type LazyPerson = Getters<Person>
/*
{
  getName: () => string
  getAge: () => number
}
*/

可以看到,连 Obsidian 的代码块的编译器都不认识这个语法,说明用这个语法之前可以先看看 TS 的版本。

这个功能的另一个很有用的能力是移除一个很大的对象类型里的某几个属性:

  
type RemoveKindField<Type> = {
  [Key in keyof Type as Exclude<Key, "kind">]: Type[Key]
};

interface Circle {
  kind: "circle";
  radius: number;
}

type KindlessCircle = RemoveKindField<Circle>; // { radius: number; }

你也可以只保留其中的某些属性:

  
type Include<T, U> = T extends U ? T : never

type KeepAB<T extends { a: unknown, b: unknown }> = {
  [K in keyof T as Include<'a' | 'b', K>]: T[K]
}

type Obj = {
  a: string;
  b: number;
  c: boolean;
  d: string[];
}

type OnlyAB = KeepAB<Obj>
/**
 * type OnlyAB = {
 *   a: string;
 *   b: number;
 * }
 */

或者将对象类型的字符串值作为键,在填充一个值类型:

  
type GetValueToKey<T extends { [O: string]: string }, U> = {
  // 这里是核心
  [K in T[keyof T]]: U
}

type Obj = {
  uselessKey1: 'Jobs',
  uselessKey2: 'Siven',
}

type NewObj = GetValueToKey<Obj, boolean>
/**
 * type OnlyAB = {
 *   Jobs: boolean;
 *   Siven: boolean;
 * }
 */

我只能说,针不戳!

模版字符串类型

模版字符串类型建立在 string 类型之上。和 JS 的模版字符串一样,不过 TS 这个是用来构建新的字符串类型的。

  
type World = 'World!'

type Greeting = `hello ${World}`

在模版字符串中使用联合类型,也会被类型分配:

  
type Lang = 'en' | 'ch' | 'jp'

type LangId = `${Lang}_id` // type LangId = "en_id" | "ch_id" | "jp_id"

这很适合写一写很复杂,但是又有规律可循的字符串常量。

内置字符串操作

TS 内置了一些字符串操作,这些操作内置在了编译器中,在 .d.ts 文件中也找不到。如果将鼠标移至这些工具泛型上时,会显示 intrinsic,表示你别找了,内置的。

Uppercase<StringType>

将提供的每一个字母都大写:

  
type Hello = 'Hello World!'

type UppercaseHello = Uppercase<Hello>// type UppercaseHello = "HELLO WORLD!"
Lowercase<StringType>

和上面一个一样,这个是小写。

Capitalize<StringType>

首字母大写,这个虽然内置了,但是这个很容易实现(可能容易):

  
type MyCapitalize<S extends string> = S extends `${infer Head}${infer Tail}` ? `${Uppercase<Head>}${Tail}` : S;

type T = MyCapitalize<'foobar'> // type T = "Foobar"

这道题在 TS 类型挑战的中等里,题号#110。

Uncapitalize<StringType>

首字母小写。

Class-上

TS 对 ES6 提出的 Class 关键字全面支持。

成员(Fields)

TS 可以为成员添加对应的类型注解:

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

const pt = new Point()
pt.x = 0;
pt.y = 0;

TS 一样支持类字段初始化器,这个是 JS 的语言特性:

  
class Point {
  x = 0;
  y = 0;
}

当开启严格模式时,TS 会开启 strictPropertyInitialization 配置项,开启后 TS 会检查我们是否在构造函数中对字段进行了初始化:

  
class Greet {
  name: string; // Property 'name' has no initializer and is not definitely assigned in the constructor.
}

如果我们使用了其他的三方库或者其他的确定的方法,对字段进行了初始化,也可以使用 ! 来标记这个字段:

  
class OKGreeter {
  // Not initialized, but no error
  name!: string;
}
readonly

类成员支持 readonly 修饰符,这会让这个成员在构造函数外无法被修改

  
class Greeter {
  readonly name: string
  constructor(name: string) {
    this.name = name // 可以修改
    this.name = name + 'aa' // 只要是在构造函数中,都可以修改
  }
  set changeName(name: string) {
    this.name = name // Cannot assign to 'name' because it is a read-only property.
  }
}

构造函数

类构造函数和函数类似,支持重载:

  
class Point {
  constructor(x: number, y: string);
  constructor(s: string);
  constructor(xs: number, y?: string); {
    // 实现
  }
}
super()

和 JS 一样,如果子类想要在构造函数中使用 this 关键字,需要先调用 super() 来初始化父类实例:

  
class Base {
  k = 4;
}

class Derived extends Base {
  constructor() {
    this.k; // 'super' must be called before accessing 'this' in the constructor of a derived class.
    super();
  }
}

方法

类中的函数属性叫做方法,方法在 JS 中被挂载到实例的原型上。

  
class Point {
  x = 10;
  y = 10;

  scale(n: number): void {
    this.x = this.x * n;
    this.y = this.y * n;
  }
}

注意,在方法内部也必须一直使用 this. 来访问成员,如果不使用 this 访问,则会去访问作用域中的变量(在 JS 也是一样):

  
class Point {
  x = 1;
  changeX() {
    x = 2; // Cannot find name 'x'. Did you mean the instance member 'this.x'?
  }
}

Getters / Setters

类属性也可以有存取器,和 JS 一样。

  
class C {
  _length = 0;
  get length() {
    return this._length;
  }
  set length(l) {
    this._length = l;
  }
}

TS 会自动推断我们的存取器:

  • 如果只有 get,没有 set,则该成员为 readonly
  • 如果 set 函数没有指定参数类型,那么会使用 get 的返回值类型
  • getset 必须是一样的成员可见性(要么一起是 public,要么一起是 private

索引签名

类可以声明索引签名:

  
class Foo {
  [s: string]: boolean | ((s: string) => boolean);

  check(s: string) {
    return this[s] as boolean
  }
}

但是一般不这么写,类成员一般都是确定的。

类的继承

implements

我们可以使用 implements 关键字,表示该类实现了某个接口。如果类的实现与接口不符,则会报错:

  
interface Pingable {
  ping(): void;
}

class Sonar implements Pingable {
  ping() {
    console.log('ping')
  }
}

class Ball implements Pingable {
  // Class 'Ball' incorrectly implements interface 'Pingable'.
  // Property 'ping' is missing in type 'Ball' but required in type 'Pingable'.
  pong() {
    console.log('pong')
  }
}

一个类也可以实现多个接口。要注意的是,implements 不会改变类的类型和方法,我们需要自己对其进行实现。

extends

真正的运行时继承,这个其实是 JS 的,TS 只是对其进行了类型规范。

子类继承父类后,会拥有父类的所有属性和方法:

  
class Person {
  name: string

  constructor(name: string) {
    this.name = name
  }

  getName() {
    return this.name
  }
}

class Student extends Person {
  grade: number

  constructor(name: string, grade: number) {
    super(name)
    this.grade = grade
  }

  getGrade() {
    return this.grade
  }
}

const person = new Person('Jobs')

const student = new Student('Siven', 2)

student.getName() // 'Siven'
student.getGrade() // 2
重写方法

子类可以重写父类的方法:

  
class Person {
  name: string

  constructor(name: string) {
    this.name = name
  }

  introduce() {
    return `I am ${this.name}.`
  }
}

class Student extends Person {
  grade: number

  constructor(name: string, grade: number) {
    super(name)
    this.grade = grade
  }

  introduce() {
    // 重写父类方法。使用 super 调用父类方法
    return `${super.introduce()} Grade is ${this.grade}.`
  }
}

const person = new Person('Jobs')

const student = new Student('Siven', 2)

student.introduce() // "I am Siven. Grade is 2."

多态

面相对象三大特征:继承、封装、多态。

在 TS 中,子类实例可以赋值给父类引用:

  
class Person {}

class Student extends Person {}

let person: Person = new Student() // 完全可以

如果子类在重写父类方法时,不遵从父类的类型,TS 会报错。比如下面这个例子,子类将父类方法的参数改成了必须参数,乍一看没有问题:

  
class Base {
  // ...
  greet() {
    console.log('hello!')
  }
}

class Derived extends Base {
  // ...
  greet(name: string) { // Property 'greet' in type 'Derived' is not assignable to the same property in base type 'Base'. Type '(name: string) => void' is not assignable to type '() => void'.
    console.log(`Hello, ${name}`)
  }
}

但是 TS 会报错,说我们无法将子类方法赋值给父类方法,其实就是考虑到了多态的问题,当使用父类引用来调用子类方法时,不传这个值会导致运行时崩溃:

  
const b: Base = new Derived()

b.greet() // 崩溃

修改方法是将其变为可选属性(或默认值):

  
class Derived extends Base {
  // ...
  greet(name?: string) {
    console.log(`Hello, ${name || 'defaultName'}`)
  }
}

Class-中

仅有类型声明的成员

当我们配置了 target >= ES2022 或者 useDefineForClassFieldstrue 时,类字段的初始化会在父类构造函数完成后进行,这可能会覆盖父类设置的任何值。这可能会导致一些问题,例如当你只想为继承的字段重新声明更精确的类型。

[!note] Class Fields 使用 class X { a = 1 } 这种直接在类中初始化的形式,叫做 Class Fields,是 ES 2022 提出的。这里面的 a 属性,会在构造函数之前被赋值。

  
interface Animal {
  dateOfBirth: any;
}

interface Dog extends Animal {
  breed: any;
}

class AnimalHouse {
  resident: Animal;
  constructor(animal: Animal) {
    this.resident = animal;
  }
}

class DogHouse extends AnimalHouse {
  constructor(dog: Dog) {
    super(dog);
  }
}

const dogHouse = new DogHouse({ dateOfBirth: '2022', breed: 'someBreed' })

dogHouse.resident // Animal 类型

上面的代码中,类型被推断为了模糊的 Animal 类。我们可以明确的指定其为更精确的 Dog 类。

  
class DogHouse extends AnimalHouse {
  declare resident: Dog
  constructor(dog: Dog) {
    super(dog);
  }
}

初始化顺序

JS 类中的初始化顺序有些乱,比如下面这个例子:

  
class Base {
  name = 'base'
  constructor() {
    console.log('My name is ' + this.name)
  }
}

class Derived extends Base {
  name = 'derived'
}

const d = new Derived() // "My name is base"。不是 derived

上面的执行流程为:

  • 基类成员被初始化(name = 'base'
  • 基类构造函数被调用。打印 "My name is base"
  • 子类成员被初始化(name = 'derived'
  • 子类构造函数被调用。因为子类没有显示声明构造函数,所以默认的构造函数什么都没做。

上面的流程意味着基类的构造函数调用时,找到的 name 属性并不是子类的,因为子类这时候还没有初始化属性。

成员可见性

可以使用成员修饰符来指定哪些成员无法在外部访问(TS 自己的修饰符,和新的 ES 特性不是一个东西)。

public

默认的成员修饰符是 public,一个 public 成员可以在任何位置访问:

  
class Greeter {
  public greet() {
    console.log('hi!')
  }
}

const g = new Greeter()
g.greet()

这个修饰符一般可以不写,是默认的。

protected

protected 成员只能在类内部和子类中访问,不能在外部通过实例访问:

  
class Greeter {
  public greet() {
    console.log('hi!')
  }
  protected getName() {
    return 'hi'
  } 
}

class SpecialGreeter extends Greeter {
  public howdy() {
    console.log("Howdy, " + this.getName())
  }
}

const g = new SpecialGreeter()
g.greet() // OK
g.getName() // Property 'getName' is protected and only accessible within class 'Greeter' and its subclasses.

如果一个子类想要暴露 protected 成员,可以将其重新声明为 public

  
class Base {
  protected m = 10
}

class Derived extends Base {
  // 未加修饰符,所以是 public
  m = 15
}

const d = new Derived()
d.m // OK
private

private 修饰符类似 protected,但是不允许子类访问:

  
class Base {
  private x = 0
}

const b = new Base()
b.x // Property 'x' is private and only accessible within class 'Base'.

class Derived extends Base {
  showX() {
    console.log(this.x) // Property 'x' is private and only accessible within class 'Base'.
  }
}

静态成员

静态成员属于类本身。

  
class MyClass {
  static x = 0;
  static printX() {
    console.log(MyClass.x)
  }
}

console.log(MyClass.x)
MyClass.printX()

静态成员同样支持访问修饰符:

  
class MyClass {
  private static x = 0
}

console.log(MyClass.x) // Property 'x' is private and only accessible within class 'MyClass'.

静态成员同样可以被继承:

  
class Base {
  static getGreeting() {
    return 'Hello World'
  }
}

class Derived extends Base {
  myGreeting = Derived.getGreeting()
}
静态块 Static Blocks

这个是 ES 特性。

静态块可以用于初始化静态成员,因为直接使用 Class Fields 的形式初始化不利于一些复杂逻辑的初始化。

静态块会在类构建时调用。

  
class Base {
  static #x = 0

  static {
    Base.#x = getX()
  }
}

Class-下

泛型类

类和接口类似,都是描述对象类型的蓝图,所以也可以使用泛型。当泛型类被实例化时,他的类型参数会和函数调用一样被推断出来。

  
class Box<T> {
  contents: T;
  constructor(value: T) {
    this.contents = value
  }
}

const b = new Box('hello!')

泛型类在静态成员上是不可以使用的:

  
class Box<T> {
  static defaultValue: T; // Static members cannot reference class type parameters.
}

这也很容易理解,因为这个成员属于类,只有一份,不需要泛型来约束。

this 的类型

在类中,有一个特殊的变量 this 被动态的推断类型。

  
class Box {
  contents = ''
  set (value: string) { // (method) Box.set(value: string): this
    this.contents = value
    return this
  }
}

这个方法 set 中,返回值类型为 this,会被自动推断为运行时的实例类型。

参数属性

如果觉得每次在构造函数中一遍遍初始化很复杂,可以使用参数属性简写:

  
class Params {
  constructor(
    public readonly x: number,
    protected y: number,
    private z: number
  ) {
    // 不再需要在函数体内赋值
  }
}

上面的代码会被编译为:

  
"use strict";
class Params {
  constructor(x, y, z) {
    this.x = x;
    this.y = y;
    this.z = z;   
  }
}

抽象类和抽象成员

抽象类中只有声明,没有实现,抽象类只能作为其他类的基类,不能使用 new 实例化,和接口的作用类似。

  
abstract class Base {
  abstract getName(): string
  printName() {
    console.log('Hello, ' + this.getName())
  }
}

const b = new Base() // Cannot create an instance of an abstract class.

上面我们试图实例化 Base 类,但是会报错。我们可以使用派生类来继承这个基类,实现其中的抽象成员:

  
class Derived extends Base {
  getName() {
    return 'Hello World!'
  }
}

const d = new Derived()
d.printName()
抽象构造函数签名

如果想要接受抽象类作为参数,我们可能会写出如下代码:

  
function greet(ctor: typeof Base) {
  const instance = new ctor() // Cannot create an instance of an abstract class.
  instance.printName()
}

TS 会告知我们无法实例化一个抽象类。但是我们可能需要接受任何对其的实现类,此时我们可以这么声明类型:

  
function greet(ctor: new () => Base) {
  const instance = new ctor()
  instance.printName()
}

greet(Derived) // OK
greet(Base) // Argument of type 'typeof Base' is not assignable to parameter of type 'new () => Base'.

现在 TS 可以正确的接受那些已经完成了实现的可执行构造函数,但是不接受那些抽象类的构造函数了。

类与鸭式辨型

TS 使用鸭式辨型来鉴别类/对象类型。只要类/对象拥有一样的属性,则会认为他们是同一个类/对象:

  
class Point1 {
  x = 0;
  y = 0;
}
class Point2 {
  x = 0;
  y = 0;
}

const p: Point1 = new Point2() // OK

同理,子类的继承也是:

  
class Person {
  name: string
  age: number
}

class Student {
  name : string
  age: number
  grade: number
}

const student: Person = new Student() // OK

Modules

在 JS 中,由于历史原因,模块化有很多标准,现在最主要的模块化规范是 CJS 和 EMS,当然,ESM 比 CJS 更有前途,他是官方的标准,但是我们现在仍然需要 CJS 的规范来管理包。

定义模块

和 ES6 一样,任何包含顶级导入和导出的文件都被视作一个模块。而其他没有顶级导入导出的文件将被视为脚本,脚本内容可以在全局作用域中使用。

如果想要将一个脚本文件当做模块,需要在其中添加一个空的顶级导出:

  
export {}

模块在他们自己的作用域中执行,而不是全局。模块中的变量、函数、类在外部不可见,除非他们将其导出。

TS 模块

在 TS 中编写基于模块的代码时,需要注意三件事:

  • 语法:想要用什么语法导入导出?
  • 模块解析:模块名称(或路径)与磁盘上的文件之间的关系是什么?
  • 模块输出目标:我们编译好的 JS 模块应该是哪种形式?

ESM 语法

这些东西都是基本的 ES 语法:exportexport defaultimportimport { old as new} from import * as New等等,我就不再写一遍了。

TS 类型导入导出

类型可以使用相同的语法导入和导出:

  
// @filename: animal.ts
export type Cat = { breed: string; yearOfBirth: number };

export interface Dog {
  breeds: string[];
  yearOfBirth: number;
}

// @filename: app.ts
import { Cat, Dog } from "./animal.js";
type Animals = Cat | Dog;

但是这样可能会在某些情况下导致一些问题,比如使用 Babel 等工具编译时,如果是一个一个文件进行编译,Babel 并不能确定我们导入的是类型还是变量。所以 TS 提供了 import type 关键字,用于仅导入类型:

  
// @filename: app.ts
import type { Cat, Dog } from "./animal.js";
type Animals = Cat | Dog;

或是使用内联的方式引入 type(TS 4.5 以上):

  
import { createCatName, type Cat, type Dog } from "./animal.js";

CJS 语法

如果你导入的是一个 CJS 模块,你可以使用 import = require() 语法:

  
const maths = require("./maths");

模块解析策略

通过 moduleResolution 配置项,我们可以指定 TS 的模块解析策略。解析策略是指,编译器确定导入的模块指向的具体是什么的过程。

  • classic:假设当前文件路径为 /root/src/folder/A.ts,当以一个非相对路径导入 moduleB 时,比如 import { b } from "moduleB",TS 会试图从以下路径寻找模块 "moduleB"
    1. /root/src/folder/moduleB.ts
    2. /root/src/folder/moduleB.d.ts
    3. /root/src/moduleB.ts
    4. /root/src/moduleB.d.ts
    5. /root/moduleB.ts
    6. /root/moduleB.d.ts
    7. /moduleB.ts
    8. /moduleB.d.ts
  • node:和 Node 的解析策略一样。假如当前文件为 /root/src/moduleA.js,导入 import { b } from "./moduleB"
    1. /root/src/node_modules/moduleB.js
    2. /root/src/node_modules/moduleB/package.json (假如指定了 "main" 属性)
    3. /root/src/node_modules/moduleB/index.js
    4. /root/node_modules/moduleB.js
    5. /root/node_modules/moduleB/package.json (假如指定了 "main" 属性)
    6. /root/node_modules/moduleB/index.js
    7. /node_modules/moduleB.js
    8. /node_modules/moduleB/package.json (假如指定了 "main" 属性)
    9. /node_modules/moduleB/index.js

模块输出配置

有两个配置项配置了 TS 的输出:

  • target:决定 JS 的版本(版本降级)。
  • module:决定 JS 使用哪种模块规范。

如果我们想支持老旧的浏览器,可以选择 较低版本的 target

module,决定 JS 的模块规范,假如我们有以下 TS 代码。

  
import { valueOfPi } from "./constants.js";

export const twoPi = valueOfPi * 2;

如果我们指定 "module": "ES2022",会被编译为:

  
import { valueOfPi } from "./constants.js";

export const twoPi = valueOfPi * 2;

而如果我们指定的是 "module": "CommonJS"

  
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.twoPi = void 0;
const constants_js_1 = require("./constants.js");
exports.twoPi = constants_js_1.valueOfPi * 2;

关于模块这部分,更多的会在后一章的 Reference Module 介绍,因为现在的项目配置都需要工程化 TS,所以还是比较重要的。

C. Reference

工具类型

为了使类型转换更简单,TS 提供了很多工具泛型。这些工具泛型全局可用。

Awaited<Type>

这个泛型可以解开 Promise 的内部类型:

  
type A = Awaited<Promise<string>>; // type A = string

type B = Awaited<Promise<Promise<number>>>; // type B = number

type C = Awaited<boolean | Promise<number>>; // type C = number | boolean

这个在 TS 挑战中,是一道简单题,题号 #189

我们可以自己实现:

  
type Thenable<T> = { then: (onfulfilled: (arg: T) => any) => any }

type MyAwaited<T extends Thenable<any>> = T extends Thenable<infer U>
  ? U extends Thenable<any>
    ? MyAwaited<U>
    : U
  : T

Partial<Type>

语义为「部分」。其实就是将一个对象的所有属性变为可选,这样新的类型就可以是旧类型任意子集了:

  
interface Todo {
  title: string;
  description: string;
}

// 覆盖第一个参数的属性,第二个参数为第一个参数的子集
function updateTodo(todo: Todo, fieldsToUpdate: Partial<Todo>) {
  return { ...todo, ...fieldsToUpdate };
}

Required<Type>

将所有属性变为必选。自己很容易实现。

Readonly<Type>

将所有属性变为只读。自己很容易实现。

Record<Keys, Type>

构造一个对象类型,键值和类型由泛型决定:

  
interface CatInfo {
  age: number;
  breed: string;
}

type CatName = "miffy" | "boris" | "mordred";

const cats: Record<CatName, CatInfo> = {
  miffy: { age: 10, breed: "Persian" },
  boris: { age: 5, breed: "Maine Coon" },
  mordred: { age: 16, breed: "British Shorthair" },
};

这个比较常用,可以用于指定一些模糊的对象类型:

  
Record<string | symbol | number, any>

Pick<Type, Keys>

构造一个新的对象类型,从旧的对象类型中选出指定的属性:

  
interface Todo {
  title: string;
  description: string;
  completed: boolean;
}

type TodoPreview = Pick<Todo, "title" | "completed">;

const todo: TodoPreview = {
  title: "Clean room",
  completed: false,
};

Omit<Type, Keys>

构造一个新的对象类型,但是移除掉指定的属性。和 Pick 是相反的:

  
interface Todo {
  title: string;
  description: string;
  completed: boolean;
  createdAt: number;
}

type TodoPreview = Omit<Todo, "description">;

const todo: TodoPreview = {
  title: "Clean room",
  completed: false,
  createdAt: 1615544252770,
};

Exclude<UnionType, ExcludedMembers>

从联合类型中移除某些类型。

  
type T1 = Exclude<"a" | "b" | "c", "a" | "b">; // type T1 = "c"

type T2 = Exclude<string | number | (() => void), Function>; // type T2 = string | number

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; x: number }
  | { kind: "triangle"; x: number; y: number };

type T3 = Exclude<Shape, { kind: "circle" }>
/*
  type T3 =
  | { kind: "square"; x: number; }
  | { kind: "triangle"; x: number; y: number; }
*/

自己也可以实现,很简单的。在挑战中题号为 #43

  
type MyExclude<T, U> = T extends U ? never : T;

Extract<Type, Union>

在联合类型中留下指定的类型,和 Exclude 是相反的操作:

  
type T0 = Extract<"a" | "b" | "c", "a" | "f">; // type T0 = "a"

type T1 = Extract<string | number | (() => void), Function>; // type T1 = () => void

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; x: number }
  | { kind: "triangle"; x: number; y: number };

type T2 = Extract<Shape, { kind: "circle" }>
/*
type T2 = { kind: "circle"; radius: number; }
*/

自己可以实现,题号为 #898 在挑战里他叫 Include

  
type Extract<T, U> = T extends U ? T : never;

NonNullable<Type>

移除所有的 nullundefined 类型:

  
type T0 = NonNullable<string | number | undefined>; // type T0 = string | number

type T1 = NonNullable<string[] | null | undefined>; // type T1 = string[]

Parameters<Type>

获取函数的参数类型,获取到的是一个元组:

  
type T0 = Parameters<() => string>; // type T0 = []

type T1 = Parameters<(s: string) => void>; // type T1 = [s: string]

type T2 = Parameters<<T>(arg: T) => T>; // type T2 = [arg: unknown]

挑战题号 #3312

  
type Parameters<T extends (...args: any[]) => any> = T extends (...args: infer U) => any ? U : never

ConstructorParameters<Type>

获取类的构造函数参数:

  
class C {
  constructor(a: number, b: string) {}
}

type T3 = ConstructorParameters<typeof C>;

ReturnType<Type>

获取函数返回值类型:

  
type T0 = ReturnType<() => string>; // type T0 = string

type T1 = ReturnType<(s: string) => void>; // type T1 = void

type T2 = ReturnType<<T>() => T>; // type T2 = unknown

InstanceType<Type>

获取实例类型。

  
class C {
  x = 0;
  y = 0;
}

type T0 = InstanceType<typeof C>; // type T0 = C

type T1 = InstanceType<any>; // type T1 = any

type T2 = InstanceType<never>; // type T2 = never

这个在 Vue 3 中经常用。当我们使用 ref 获取一个组件时,可以使用它来获取组件的类型:

  
import { ref } from 'vue'
import MyComponent from './MyComponent.vue'

const myComponentRef = ref<InstanceType<typeof MyComponent>>

装饰器

介绍

使用装饰器,我们可以对已有的类、方法、属性等进行元编程,即对已有代码进行再加工。假如我们已经有了一个类,我们希望再不对代码进行大改的情况下对其进行扩充:

  
function decorator(target: typeof Animal) {
  target.say = () => {
    console.log('Animal say')
  }
}

@decorator
class Animal {
  static say: Function
}

Animal.say() // "Animal say"

启用装饰器

装饰器是实验特性,需要启用配置项:

  
// tsconfig.json
{
  "compilerOptions": {
    "target": "ES5",
    "experimentalDecorators": true,
  }
}

装饰器函数

装饰器是一种特殊类型的声明,可以附加在类声明、方法、访问器、属性或参数上。装饰器使用 @expression 的形式,其中 expression 必须为一个函数,该函数将在运行时调用。

比如,我们给定装饰器 @sealed,我们可以编写 sealed 函数:

  
function sealed(target) {
  // do something with target ...
}

装饰器工厂

我们如果直接使用装饰器,就不能在使用时传入参数来定制装饰器了,所以引入了装饰器工厂的概念。

当我们想要自定义一个如何将装饰器应用于声明时,我们可以编写一个装饰器工厂。装饰器工厂返回将在运行时调用的装饰器。

  
function color(value: string) {
  // 装饰器工厂,返回装饰器
  return function (target) {
    // 装饰器函数,可以对 value 和 target 进行操作
  }
}

类装饰器

类装饰器在类声明前被声明(紧挨着类声明)。类装饰器应用于类构造函数,可以用来监视,修改或替换类定义。

类装饰器接受一个参数,即类本身。如果类装饰器返回了一个值,该值会替换掉类声明。

  
@sealed
class Greeter {
  greeting: string;
  constructor(message: string) {
    this.greeting = message
  }
  greet() {
    return "Hello, " + this.greeting
  }
}

function sealed() {
  Object.seal(constructor)
  Object.seal(constructor.prototype)
}

@sealed被执行的时候,它将密封此类的构造函数和原型(不能删除或添加属性,但是可以修改属性值)。

我们可以使用装饰器重载构造函数:

  
@classDecorator
class Greeter {
  property = "property";
  hello: string;
  constructor(m: string) {
    this.hello = m;
  }
}

function classDecorator<T extends { new(...args: any[]): {} }>(constructor:T) {
  return class extends constructor {
    newProperty = "new property"
    hello = "override"
  }
}

console.log(new Greeter("world"))
/*
{
  "property": "property",
  "hello": "override",
  "newProperty": "new property"
}
*/

方法装饰器

方法装饰器声明在一个方法之前(紧挨着方法声明)。它会被应用到方法的属性描述符上,可以用来监视、修改或替换方法定义。

方法装饰器表达式会在运行时当作函数被调用,传入下列 3 个参数:

  1. 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象。
  2. 成员的名字。
  3. 成员的属性描述符。

如果代码输出目标版本小于 ES5,属性描述符将会是 undefined

如果方法装饰器返回一个值,它会被用作方法的属性描述符。

如果代码输出目标版本小于ES5 返回值会被忽略。

  
class Greeter {
  greeting: string;
  constructor(message: string) {
    this.greeting = message;
  }
  @enumerable(false)
  greet() {
    return "Hello, " + this.greeting;
  }
}

function enumerable(value: boolean) {
  return function (
    target: any,
    propertyKey: string,
    descriptor: PropertyDescriptor
  ) {
    descriptor.enumerable = value;
  };
}

当配置了 enumerablefalse 后,for...in 不再可见该属性(但是 Reflect.ownKeys() 依旧可见该属性)。

模块

加载其他 JS 模块

如果我们在项目中需要加载一个非 TS 模块,那么首先我们需要查看是否社区已经有对应的 TS 声明(@types/xxx),如果没有,我们就需要自己编写对应的声明。

具体的编写方法是在 .d.ts 文件中,声明该库所暴露的所有 API。比如我们下面声明了部分 Node 中的 urlpath 模块:

  
declare module "url" {
  export interface Url {
    protocol?: string;
    hostname?: string;
    pathname?: string;
  }
  export function parse(urlStr: string, parseQueryString?, slashesDenoteHost?): Url;
}

declare module "path" {
  export function normalize(p: string): string;
  export function join(...paths: any[]): string;
  export let sep: string;
}

在声明后,我们就可以使用三斜线指令引入该声明,并正常的加载模块了:

  
/// <reference path="node.d.ts"/>
import * as URL from "url";
let myUrl = URL.parse("http://www.typescriptlang.org");

如果我们懒得编写对应模块的具体信息,也可以简写,这会让内部的所有变量类型为 any

  
declare module "hot-new-module";

模块的解析

模块解析是指编译器在查找导入模块内容时所遵循的流程。

假设有一个导入语句 import { a } from "moduleA"。为了去检查任何对 a 的使用,编译器需要准确的知道它表示什么,并且需要检查它的定义 moduleA

这时,编译器需要找到 moduleA 到底在哪里,是在 .ts 中,还是 .tsx.d.ts 中。

首先,编译器会试图定位模块位置,这一步编译器会遵循几种策略之一:classicnodenodenextbundler(TS 5.0)。

"moduleResolution": "classic"

假如当前引入为:

  
// 文件:/root/src/folder/index.js
import 'pkg';

TS 会依次向上找对应的文件(不会去找 node_modules):

  1. /root/src/folder/pkg.js
  2. /root/src/pkg.js
  3. /root/pkg.js
  4. /pkg.js

可以发现这种策略用的不多。

"moduleResolution": "node"

这个是 Node 的 Commonjs 解析策略,也是用的最多的策略。

  
// 文件 /root/src/index.js
import "pkg";

TS 会去查找对应的 node_modules 目录,如果找不到,会依次向上找 node_modules 目录:

  1. 同级目录的 node_modules 找同名的 JS 文件: /root/src/node_modules/pkg.js
  2. 同级目录 node_modules 里面找包含 package.json 的名为 pkg 文件夹:/root/src/node_modules/pkg/package.json
  3. 同级目录 node_modules 里面找包含 index.js 的名为 pkg 文件夹 /root/src/node_modules/pkg/index.js
  4. 还是找不到的话,那就往上一级目录重复前面的查找步骤
  5. /root/node_modules/pkg.js
  6. /root/node_modules/pkg/package.json
  7. /root/node_modules/pkg/index.js

插入:一些模块化的前置知识

Node 中的模块化有很多东西需要先学,学完才能看懂后面的两个配置项。

模块主入口
main 字段

我们每一个 Node 包都可以配置 package.json,配置后可以指定 main 字段,该字段指定了包的入口文件。当我们引入一个不含子路径的模块时,就会去找这个字段对应的文件。比如 lodash:

  
{
  "name": "lodash",
  "version": "4.17.21",
  "main": "lodash.js"
}

当我们直接引入 const _ = require("lodash") 时,就是去引入 main 字段对应的 lodash.js 文件。

如果我们指定了子路径,那么 Node 就会去查找包下面的指定子路径:

  
const add = require('lodash/add')

这就会去查找 node_modules/lodash/add.js

module 字段

如果一个包想要同时支持 CJS 和 ESM。就需要通过 main 来指定 CJS 的入口,通过 module 来指定 ESM 的入口。

  
{
  "name": "redux",
  "version": "4.2.1",
  "main": "lib/redux.js",
  "unpkg": "dist/redux.js",
  "module": "es/redux.js",
  "typings": "./index.d.ts",
  "files": ["dist", "lib", "es", "src", "index.d.ts"]
}
exports 字段

如果说 ESM 是模块化标准的最终解决方案,那么 package.json 的 exports 便是模块解析策略的最终解决方案。

主入口导出

类似 main 和 module 字段,我们可以使用下面的写法来配置一个模块没有写子路径时怎样导出的,也叫主入口:

  
{
  "name": "xxx",
  "exports": {
    ".": "./index.js"
  }
}

exports 中所有的路径都必须以 . 开头,这里的 . 就代表着这个包。

当然,上面这个例子可以简化为一个路径:

  
{
  "name": "xxx",
  "exports": "./index.js"
}

用户在导入这个包时,例如 import x from 'xxx' 其实会被解析到 node_modules/xxx/index.js

子路径导出

当定义子路径时,可以继续配置:

  
{
  "name": "es-module-package",
  "exports": {
    "./submodule.js": "./src/submodule.js"
  }
}

如果没有声明的子路径是不能使用的。

"moduleResolution": "nodenext"

目前前端的大部分包在这个解析模式下都不能正常使用。nodenext 严格按照最新的 Node 策略来判断一个 js 文件是 CJS 模块还是 ESM 模块,也就是满足下面两个条件一个 js 模块会被 nodejs 视为 esm 模块:

  • 最近的 package.json 设置了 "type": "module"
  • 扩展名是 .mjs

ESM 是必须要求写文件拓展名的。所以设置这个配置项之后我们要补全拓展名。

别的我也不太知道了,总之没啥人用。

"moduleResolution": "bundler"

TS 5.0 推出的新配置项。以适配打包工具。

比如 Vite,Vite 声称是基于 ESM 的打包工具,但是在声明非相对路径的模块时却不要求写扩展名。

问题就出在现有的几个模块解析策略都不能完美适配 vite + ts + esm 开发场景:

  • node:不支持 exports
  • node16 / nodenext:强制要求使用相对路径模块时必须写扩展名

这就导致 node16 / nodenext 这俩策略几乎没人用,用的最多的还是 node

于是乎,TS 5.0 新增了个新的模块解析策略:bundler。它的出现解决的最大痛点就是:可以让你使用 exports 声明类型的同时,使用相对路径模块可以不写扩展名。

命名空间

在 JS 中,如果我们担心命名冲突,我们可以选择使用匿名自执行函数来包裹代码,这样可以制造一个作用域。而在 TS 中,我们可以直接使用 namespace 来包裹,代码会在编译后,为我们包裹对应的函数作用域。

现在的模块开发已经非常成熟了,所以 namespace 不再常用。

假设我们需要编写几个字符串验证器,使用他们来完成表单校验。这个例子在整章中都会使用。

  
interface StringValidator {
  isAcceptable(s: string): boolean;
}
let lettersRegexp = /^[A-Za-z]+$/;
let numberRegexp = /^[0-9]+$/;
class LettersOnlyValidator implements StringValidator {
  isAcceptable(s: string) {
    return lettersRegexp.test(s);
  }
}
class ZipCodeValidator implements StringValidator {
  isAcceptable(s: string) {
    return s.length === 5 && numberRegexp.test(s);
  }
}
// Some samples to try
let strings = ["Hello", "98052", "101"];
// Validators to use
let validators: { [s: string]: StringValidator; } = {}
  validators["ZIP code"] = new ZipCodeValidator();
  validators["Letters only"] = new LettersOnlyValidator();

// Show whether each string passed each validator
for (let s of strings) {
  for (let name in validators) {
    let isMatch = validators[name].isAcceptable(s);
    console.log(`'${ s }' ${ isMatch ? "matches" : "does not match" } '${ name }'.`);
  }
}

当我们将所有验证器放在一个文件中时,我们后期还会不断添加新的验证器,我们担心他们会产生命名冲突。所以我们需要使用 命名空间 来重构代码:

  
namespace Validation {
  export interface StringValidator {
    isAcceptable(s: string): boolean;
  }
  
  const lettersRegexp = /^[A-Za-z]+$/;
  const numberRegexp = /^[0-9]+$/;

  export class LettersOnlyValidator implements StringValidator {
    isAcceptable(s: string) {
      return lettersRegexp.test(s);
    }
  }

  export class ZipCodeValidator implements StringValidator {
    isAcceptable(s: string) {
      return s.length === 5 && numberRegexp.test(s);
    }
  }
}

// Some samples to try
let strings = ["Hello", "98052", "101"];

// Validators to use
let validators: { [s: string]: Validation.StringValidator; } = {};
  validators["ZIP code"] = new Validation.ZipCodeValidator();
  validators["Letters only"] = new Validation.LettersOnlyValidator();

// Show whether each string passed each validator
for (let s of strings) {
  for (let name in validators) {
    console.log(`"${ s }" - ${ validators[name].isAcceptable(s) ? "matches" : "does not match" } ${ name }`);
  }
}

对应的命名空间代码会被编译为:

  
"use strict";
var Validation;
(function (Validation) {
  const lettersRegexp = /^[A-Za-z]+$/;
  const numberRegexp = /^[0-9]+$/;
  class LettersOnlyValidator {
    isAcceptable(s) {
      return lettersRegexp.test(s);        
    }
  }
  Validation.LettersOnlyValidator = LettersOnlyValidator;
  class ZipCodeValidator {
    isAcceptable(s) {
      return s.length === 5 && numberRegexp.test(s);
    }
  }
  Validation.ZipCodeValidator = ZipCodeValidator;
})(Validation || (Validation = {}));

声明合并

“声明合并” 是指编译器将针对同一个名字的两个独立声明合并为单一声明。 合并后的声明同时拥有原先两个声明的特性。 任何数量的声明都可被合并;不局限于两个声明。

基础概念

在 TypeScript 中,一个声明会创建以下三个实体之一:命名空间 namespace、类型 type 或 值 value。

命名空间声明会创建一个命名空间,通过 . 来访问内部的名字。

类型声明会创建一个类型,并绑定到指定名字上。

值声明会创建真正的变量,输出在 JS 中。

声明 namespace type value
Namespace X X
Class X X
Enum X X
Interface X
Type Alias X
Function X
Variable X

接口合并

最简单也最常见的声明合并类型是接口合并。 从根本上说,合并的机制是把双方的成员放到一个同名的接口里。

  
interface Box {
  height: number;
  width: number;
}
interface Box {
  scale: number;
}
let box: Box = {height: 5, width: 6, scale: 10};

接口的非函数的成员应该是唯一的。如果它们不是唯一的,那么它们必须是相同的类型。如果两个接口中同时声明了同名的非函数成员且它们的类型不同,则编译器会报错。

对于函数成员,每个同名函数声明都会被当成这个函数的一个重载。 同时需要注意,当接口 A与后来的接口 A合并时,后面的接口具有更高的优先级。

  
interface Cloner {
  clone(animal: Animal): Animal;
}
interface Cloner {
  clone(animal: Sheep): Sheep; 
}
interface Cloner {
  clone(animal: Dog): Dog;
  clone(animal: Cat): Cat;
}

这三个接口合并后:

  
interface Cloner {
  clone(animal: Dog): Dog;
  clone(animal: Cat): Cat;
  clone(animal: Sheep): Sheep;
  clone(animal: Animal): Animal;
}

命名空间合并

与接口相似,同名的命名空间也会合并其成员。 命名空间会创建出命名空间和值,我们需要知道这两者都是怎么合并的。

对于命名空间的合并,模块导出的同名接口进行合并,构成单一命名空间内含合并后的接口。

对于命名空间里值的合并,如果当前已经存在给定名字的命名空间,那么后来的命名空间的导出成员会被加到已经存在的那个模块里。

  
namespace Animals {
  export class Zebra { }
}
namespace Animals {
  export interface Legged { numberOfLegs: number; }
  export class Dog { }
}

会被合并为:

  
namespace Animals {
  export interface Legged { numberOfLegs: number; }
  
  export class Zebra { }
  export class Dog { }
}

除了这些合并外,你还需要了解非导出成员是如何处理的。 非导出成员仅在其原有的(合并前的)命名空间内可见。这就是说合并之后,从其它命名空间合并进来的成员无法访问非导出成员。

  
namespace Animal {
  let haveMuscles = true;
  export function animalsHaveMuscles() {
    return haveMuscles;
  }
}

namespace Animal {
  export function doAnimalsHaveMuscles() {
    return haveMuscles; // Error, because haveMuscles is not accessible here
  }
}

因为 haveMuscles并没有导出,只有 animalsHaveMuscles函数共享了原始未合并的命名空间可以访问这个变量。 doAnimalsHaveMuscles函数虽是合并命名空间的一部分,但是访问不了未导出的成员。

枚举

使用枚举我们可以定义一系列的相关常量。

数字常量

数字枚举的值是数字。

  
enum Direction {
  Up = 1,
  Down,
  Left,
  Right
}

如上,我们定义了一个数字枚举, Up使用初始化为 1。 其余的成员会从 1开始自动增长。 换句话说, Direction.Up的值为 1, Down为 2, Left为 3, Right为 4

如果不使用初始化器,即不声明 Up = 1Up 的值就是 0,后面依次递增。

上面的枚举会被编译为:

  
"use strict";
var Direction;
(function (Direction) {
  Direction[Direction["Up"= 1= "Up";
  Direction[Direction["Down"= 2= "Down";
  Direction[Direction["Left"= 3= "Left";
  Direction[Direction["Right"= 4= "Right";
})(Direction || (Direction = {}));

可以看到他被编译成了一个双向的对象。

在我们使用时,可以通过枚举的名字来访问枚举的类型:

  
enum Response {
  No = 0,
  Yes = 1,
}

function respond(recipient: string, message: Response): void {
  // ... 
}

respond("Princess Caroline", Response.Yes)

字符串枚举

字符串枚举的实际值是字符串,在运行时会被编译为一个单项的对象。

  
enum Direction {
  Up = "UP",
  Down = "DOWN",
  Left = "LEFT",
  Right = "RIGHT",
}

被编译为:

  
"use strict";
var Direction;
(function (Direction) {
  Direction["Up"= "UP";
  Direction["Down"= "DOWN";
  Direction["Left"= "LEFT";
  Direction["Right"= "RIGHT";
})(Direction || (Direction = {}));

可以看到这个不再是双向的对象了。

三斜线指令

三斜线指令是包含单个XML标签的单行注释。 注释的内容会做为编译器指令使用。

三斜线指令仅可放在包含它的文件的最顶端。 如果它们出现在一个语句或声明之后,那么它们会被当做普通的单行注释,并且不具有特殊的涵义。

/// <reference path="...">

/// <reference path="..."> 指令是三斜线指令中最常见的一种。 它用于声明文件间的依赖。

三斜线引用告诉编译器在编译过程中要引入的额外的文件。

/// <reference path="..." />

与 /// <reference path="..." /> 指令相似(用于声明_依赖_), /// <reference types="..." /> 指令声明了对某个包的依赖。

对这些包的名字的解析与在 import 语句里对模块名的解析类似。 可以简单地把三斜线类型引用指令当做 import 声明的包。

例如,把 /// <reference types="node" /> 引入到声明文件,表明这个文件使用了 @types/node/index.d.ts 里面声明的名字; 并且,这个包需要在编译阶段与声明文件一起被包含进来。

D. Project Configuration

tsconfig.json

如果一个目录下存在一个 tsconfig.json 文件,那么意味着这个目录是 TypeScript 项目的根目录。tsconfig 文件中指定了用来编译这个项目的根文件和编译选项。

在我们编译时:

  • 不带任何输入文件的情况下调用 tsc ,编译器会从当前目录开始去查找 tsconfig.json 文件,逐级向上搜索父目录。
  • 不带任何输入文件的情况下调用 tsc ,且使用命令行参数 --project (或 -p )指定一个包含 tsconfig.json 文件的目录。

当命令行上指定了输入文件时, tsconfig.json 文件会被忽略

compilerOptions

compilerOptions 可以被忽略,这时编译器会使用默认值。

  • module:编译目标的模块化规范。
  • noImplicitAny:不允许隐式 any
  • target:编译目标的 JS 版本。
  • moduleResolution:模块解析策略,详见「模块」一章。
  • strict:严格模式,一般都会开启。

filesincludeexclude

"files" 指定一个包含相对或绝对文件路径的列表。 "include" 和 "exclude" 属性指定一个文件 glob 匹配模式列表。支持的 glob 通配符有:

  • * 匹配 0 或多个字符(不包括目录分隔符)
  • ? 匹配一个任意字符(不包括目录分隔符)
  • **/ 递归匹配任意子目录

这个是 Vite 的实例 TS 配置中的一部分,该配置描述了 TS 需要包含 src 目录下的所有 .ts.d.ts.tsx.vue 文件。

  
{
  "include": [
    "src/**/*.ts",
    "src/**/*.d.ts",
    "src/**/*.tsx",
    "src/**/*.vue"
  ],
}

如果 "files" 和 "include" 都没有被指定,编译器默认包含当前目录和子目录下所有的 TS 文件( .ts , .d.ts 和 .tsx ),排除在 "exclude" 里指定的文件。

使用 "include" 引入的文件可以使用 "exclude" 属性过滤。 然而,通过 "files" 属性明确指定的文件却总是会被包含在内,不管 "exclude" 如何设置。 如果没有特殊指定, "exclude" 默认情况下会排除 node_modules , bower_components , jspm_packages 和 <outDir> 目录。

任何被 "files" 或 "include" 指定的文件所引用的文件也会被包含进来。 A.ts 引用了 B.ts ,因此 B.ts 不能被排除,除非引用它的 A.ts 在 "exclude" 列表中。

@typestypeRootstypes

默认所有可见的 @types 目录都会在编译时被包含进来。node_modules/@types 目录下的所有包都可见。

如果指定了 typeRoots 目录,那么只有 typeRoots 下面的包才会被包含进来。

  
{
  "compilerOptions": {
    "typeRoots" : ["./typings"]
  }
}

这个配置文件会包含所有 ./typings 下面的包,而不包含 ./node_modules/@types 里面的包。

如果指定了 types ,只有被列出来的包才会被包含进来。 比如:

  
{
  "compilerOptions": {
    "types" : ["node", "lodash", "express"]
  }
}

这个 tsconfig.json 文件将仅会包含 ./node_modules/@types/node , ./node_modules/@types/lodash 和 ./node_modules/@types/express 。 node_modules/@types/* 里面的其它包不会被引入进来。

指定 "types": [] 来禁用自动引入 @types 包。

工程引用

在一整个工程中,我们并不是全部的代码都需要在同一个配置环境下运行。比如 Vite 创建的 Vue-TS 模版,就有两个 tsconfig 文件,分别为 tsconfig.jsontsconfig.node.json 文件。

首先看 tsconfig.json。这个配置文件作用于我们的所有源码,可以看到 includes 配置项包含了 src 目录下的所有 TS 源码。然后通过 references 配置项引用了 tsconfig.node.json 文件:

  
{
  "references": [{
    "path": "./tsconfig.node.json"
  }]
}

然后转到 tsconfig.node.json 中,主要有两个配置项较为重要:

  
{
  "compilerOptions": {
    "composite": true,
  },
  "include": ["vite.config.ts"]
}

因为是被引用的工程,所以必须启用 composite 设置。这个配置用于帮助TypeScript快速确定引用工程的输出文件位置。

include 配置项声明了这个 tsconfig.node.json 是用来配置我们的 vite.config.ts 文件的。

所以 Vite 的逻辑很简单,通过 tsconfig.json 配置我们的源码的 TS 配置,再通过工程引用来通过 tsconfig.node.json 来配置我们的外层配置文件的 TS 配置。

工程引用

tsconfig.json 增加了一个新的顶层属性 references 。它是一个对象的数组,指明要引用的工程:

  
{
  "compilerOptions": {
    // The usual
  },
  "references": [
    { "path": "../src" }
  ]
}

每个引用的 path 属性都可以指向到包含 tsconfig.json 文件的目录,或者直接指向到配置文件本身(名字是任意的)。

当你引用一个工程时,会发生下面的事:

  • 导入引用工程中的模块实际加载的是它输出的声明文件( .d.ts )。
  • 如果引用的工程生成一个 outFile ,那么这个输出文件的 .d.ts 文件里的声明对于当前工程是可见的。
  • 构建模式(后文)会根据需要自动地构建引用的工程。

当你拆分成多个工程后,会显著地加速类型检查和编译,减少编辑器的内存占用,还会改善程序在逻辑上进行分组。