TypeScript

TypeScript 和 JavaScript 的关系

TS 在语法上是 JS 的超集:

  • 所有 JS 代码都是合法的(可运行的) TS 代码
    • 只要 JS 程序没有语法错误,它就是一个可运行的 TS 程序
    • TS 类型检查可能提示错误,但不阻碍编译和运行 JS 代码

“TypeScript 是 JavaScript 的超集”

“TypeScript 是带类型的 JavaScript ”

  • TS 代码不一定是合法的(可运行的) JS 代码
    • TS 添加了额外的类型声明,以及少量特殊语法

TypeScript 类型系统概述

  • 即使 TS 代码类型检查通过,它仍然可能在运行时抛出错误
    • 根本原因是运行时的真实值与 TS 对值的类型预期产生了偏差。

能够保证静态类型准确性的类型系统被称为 sound 的(类型安全)。

TS 的类型系统非常不 sound,这也不是 TS 的设计目标。

  • TS 类型系统的目标之一就是在不运行代码的情况下检测出可能产生运行时异常的代码
    • 这就是所谓的 “TS 是静态类型” 的。

这一段代码并不会产生运行错误,但 TS 仍然标出类型问题

  • 除了运行时异常,TS 还会检测代码是否符合编程预期
  • 通过类型声明告知 TS 代码的意图,它能够更好地检查出不符合预期的地方。

显式声明预期的数据类型是 “State” 这一结构,TS 错误提示会更准确

  • TS 认为某些奇怪的用法更可能是错误而不是开发人员的真实意图,因此也会发出警告或错误

TS 如何判断某种用法是否是 “奇怪的用法”?

说到底是编程风格的偏好。使用 TS 就是选择了开发 TS 的团队的偏好。

运行时代码与类型是相互独立的

  • 无法在代码运行时检查 TS 类型

instanceof 检查发生在运行时,但 Rectangle 是一个 TS 类型,它不能影响代码的运行时行为。

TS 类型是 "可擦除的":编译到  JS 时会从代码中删除所有的 interface、type 和其他类型声明。

  • TS 类型操作不能影响运行时行为

as number 是 TS 类型操作,它不能影响代码运行时的行为

  • TS 类型对运行时性能没有影响:既不会提高,也不会降低
    • ​​​​​存在开发时编译的开销
    • 生成兼容版本的 JS 时可能存在性能开销,例如使用 generator 时编译到 ES5 要引入支持库,这与原生实现存在性能差距。但这也与类型无关,取决于使用的特性和目标编译版本。

结构化类型(Structural Typing)

  • JS 是 duck typed:给一个函数传递一个具有所有正确属性的值,它不关心是如何产生这个值的,而只会使用它。TS 模拟了这种行为

TS 允许用一个 NamedVector 来调用 calculateLength,因为它的结构与 Vector2D 兼容。这就是 "结构类型化 "一词的由来。(下文会说明如何避免结构类型的问题)

与结构化类型对应的是名义类型 Nominal Typing,必须显式继承类或实现接口才被认为是该类型。

  • 结构化类型也会导致奇怪的问题

calculateLength 操作的是二维向量,而 normalize 操作的是三维向量。TS 并没有发现这个问题。

为什么可以用三维向量调用 calculateLength,尽管它的类型声明说它需要二维向量?

  • 在定义函数时,常常会假设参数有且只有声明的属性。然而并不能在 TS 的类型系统中表达这种类型,因为 TS 的类型是 "开放的"。

v 是 Vector3D,它的属性应该是 "x"、"y "或 "z",根据类型声明 axis 的值应该都是数字,但 TS 会提示错误

由于 v 可以有任何属性,所以 axis 的类型是 string。

TS 也没有理由相信 v[axis] 必定是一个数字,正如上图所示,它可能不是。

[number, number] 对应的是 {0: number, 1: number, length: 2}

[number, number, number] 对应的是 {0: number, 1: number, 2: number, length: 3}

  • Tuple 对应的结构

除了要求数字下标对应的元素外,还有隐式的 length 属性要求

将 TS 类型看作一组值的集合

在 JS 运行时,每个变量都有一个单一值。这些值包括:

  • 当 TS 检查类型时,每个变量也有一个类型。我们最好将类型理解为一组可能的值的集合
    • 例如,可以把 number 类型看成所有数字值的集合。42 -37.25 都在其中,但 "Canada"不在其中。

在初中数学课中学习过,描述集合的方式有两种:

如果值有一个 id 属性,且它可以赋值给一个 string,那么它就是 Identified 类型的

1. 枚举集合内可能的值:

2. 描述符合集合内值的条件:

  • 集合可以有无穷多元素,类型也一样,某些类型下有无穷多符合条件的值
    • 例如 string, number, 以及上面的 Identified
  • 仅次于空集的是包含单个值的集合,对应于 TS 中的字面类型(literal types)
  • 为了形成具有两个或三个值的类型,可以将单个字面类型联合(union)起来
    • Union types 对应着若干类型的集合的并集
  • 最小的集合是空集,它不包含任何值。它对应于 TS 中的 never 类型
    • 因为集合是空的,所以没有任何值可以赋值给一个 never 类型的变量。

全集对应的类型是什么?

可以这样理解 “可赋值(assignable )”

1. 一个值 assignable 一个类型,等价于 "一个值属于一个类型值的集合"

2. A 类型 assignable  B 类型,等价于 "A 类型值的集合是 B 类型值的集合的子集":

所有 Vector3 构成的集合是所有 Vector2 构成的集合的子集,或者说所有 Vector3 都是 Vector2( Vector3 都 assignable Vector2)

  • 将类型看作值的集合,有助于理解各种类型操作

可以这样理解 ”&“A & B 代表 A 类型值的集合与 B 类型值的集合的交集

A & B 表示在 A 的集合在 B 的集合的所有值,这些值必然既具有 A 的属性,又具有 B 的属性

可以这样理解 ”extends“A extends B 等价于 A 类型值的集合是 B 类型值的集合的子集

PersonSpan 中的每个值都必须有 name 属性,而且每个值都必须有一个 birth 属性。

"A 是 B 的子类型(subtype)" 等价于 ”A 类型的集合是 B 类型集合的子集“ 。

assignable, subset, extends 含义是等价的,都与 ”子集“ 是同义词

”集合“的方式更容易理解非继承关系的类型间的操作

  • 一些例子

注意:不是每一个集合都存在相应的 TS 类型,例如”所有整数组成的类型“

  • 小结

区分“值(Value)”与“类型(Type)”

  • TS 中存在两个空间
    • 类型空间 Type space:定义和使用类型的地方,会在编译时被移除
    • 值空间 Value space:包含程序中的,如变量、常量、函数、字面量,即所有运行时可访问的一切
  • 同一个标识符可以同时代表一个类型和一个值,TS 会根据上下文判断所指的是哪一个

interface Cylinder 在类型空间声明了一个类型,const Cylinder 在值空间声明了一个变量,尽管名称相同,它们不会产生冲突

  • 阅读代码时如何区分一个标识符代表的是值还是变量?
  1. 类型标注“ : ”后的一般都是类型
  2. 赋值“=”后的一般都是
  3. 编译后被移除的都是类型
  4. typeinterface 后声明的一般都是类型
  5. constlet 后声明的一般都是
  6. class enum 声明的既是值又是类型
    • class 的类型是由它的结构确定的,即与拥有相同属性和方法的 interface/type 没有区别
    • class 的值则是它的 constructor 函数

TS 中各类概念对应的空间

Typescript Playground 中多尝试,查看标识符的含义,比较编译前后的结果,可以快速掌握值与类型的区别。

  • TS 中还存在一些运算符或关键字,同时存在于值空间和类型空间,却有不同作用。
  • 在类型上下文中,typeof 作用是“输入”一个值,“返回”它的 TS 类型
    • 根据“输入”的值不同,它“返回”的类型有无限多种可能。可以将它“返回”的类型赋给其他类型,或是用于组合成更复杂的类型表达式
  • 在值上下文中,typeof 是一个 JS 运算符,可以在运行时“输入”一个值,“返回”一个描述该值运行时类型的字符串
    • 目前它的“返回值”仅有这几种:"string", "number", "boolean", "undefined", "object", "function""symbol"“bigint”
  • typeof “输入”的都是值,而不能是类型

其中,最容易引起困惑的是 typeof。

  • typeof class 的结果容易引起困惑
  • "function" 是因为 JS 中 class 就是一个函数
  • 类型所代表的含义不太直观,但可以看出它不是 Cylinder(实例类型)。它实际上代表的是构造函数的类型,可以通过对它使用 new 来验证

TS 提供了工具类型从构造函数类型获取实例类型

  • class 类型与实例类型的相互转换

也可以自行声明从实例类型获取构造函数类型的工具类型:

属性访问运算符 [] 也同时存在于两个空间

  • 在值空间,可以通过 obj['prop'] obj.prop 访问对象属性
  • 在类型空间,通过 SomeType['prop'] 可以获得某个类型下某个属性的类型
  • 可以使用基本类型(string, number, symbol 及它们的字面量类型)及其 union 类型来访问属性类型
  • ​​​this
    • 在值空间中,this 表示 JavaScript 中的函数调用上下文
    • 作为类型,this 用于表示 this 的类型,也称为“多态 this”。在实现”链式调用“时常用到。
  • &|
    • 在值空间中,&| 分别表示按位与和按位或。
    • 而在类型空间中,它们分别是交集和并集运算符
  • const 
    • 在值空间中,const 用于声明变量
    • 类型空间中,as const 可以改变字面量表达式的推断类型(下文会介绍)
  • in
    • 在值空间中,可以用于循环(for (key in object)),或者是属性检查(if ('prop' in object))
    • 在类型空间中,可以作为映射类型的一部分(Mapped Type
  • extends
    • 值空间和类型空间都用于定义类的子类(class A extends B)、
    • 类型空间还可以定义接口的子类型(interface A extends B),或泛型类型的约束(Generic<T extends number>

TS 的多余属性检查(Excess Property Checking)

当将一个对象字面量赋值给一个声明了类型的变量时,TypeScript 确保它具有该类型的属性,而且没有其他属性。这就是 “多余属性检查”。

这似乎与 TS 结构化类型行为不一致,因为 r 拥有 Room 所需的全部属性

如何理解和利用 TS 的这种行为?

通过引入中间变量,可以绕过上述检查。

在第一个例子中,触发了 TS 的 “多余属性检查”的过程,它可以检测出结构化类型系统通常会忽略的一类错误。

多余属性检查

  • 只会在特定的情况下触发:将一个对象字面量赋值给一个变量或将其作为参数传递给一个函数时,会触发多余属性检查
  • 将它与常规的结构化类型检查区分开,有助于更好地理解 TS 的类型系统

TS 为什么要这样设计?

虽然不会导致运行时错误,但 TS 认为某些奇怪的用法更可能是错误而不是开发人员的真实意图,因此也会发出警告或错误

额外的属性大多数时候都不是开发者的预期行为(拼写错误等),TS 添加额外属性检查机制来检查这种错误

额外属性检查与结构化类型的原则向矛盾,但可以理解 TS 设计者为了开发体验的一种平衡设计。

typeinterface 的区别

上面的例子在类型名称前缀中使用了 I 或 T,仅是为了区分其定义。实际代码中不应该这样做。在 TS 的早期阶段,这种约定有所应用。但现在一般被认为是不好的风格,因为是不必要的。

大多数情况下,type 与 interface 都可以实现相同功能,遵守团队约定即可

  • type 可以表示 union 类型,interface 不行
  • type 可以扩展 union 类型,interface 不行
  • type 可以很容易地表示 tuple 类型,但用 interface 非常别扭
  • interface 支持合并(augmented)

声明合并在标准库类型声明中非常常见:不同版本的 ES 中都会声明 interface Array,并向其中加入额外的数值属性/方法

优先使用类型声明,而非类型断言

看起来相似的两种声明方式,在异常情况表现不同

  • 类型声明是告诉 TS 预期的类型是什么,当 TS 推断出的类型与其不符合时会提示错误
  • 类型断言则是要求 TS 忽略其推断出的类型,强制使用指定的类型,这会阻碍 TS 发现和提示错误

在需要显式标注类型时,除非有特殊原因,总是应该优先使用类型声明

  • 实践中的典型场景

使用 reduce 的泛型显式标注类型,更不容易出错

对于常用的数据结构,可以创建自定义 type guard。

相比类型断言,能严格保证类型与运行时的值一致。

使用 filter 时,TS 不能推断出筛选后的类型。

  • 什么时候可以使用类型断言

在你确实比 TS 更了解某个类型的情况时。一般是因为这种情况下你拥有类型检查器无法获知的上下文信息。(必须确保类型与值是一致的。不要欺骗 TS!)

由于 TS 类型检测器无法访问页面的 DOM,它无法知道 `#myButton` 是一个按钮元素。

类似的,也可以通过 ! 强制指定类型为非空的

把类型断言封装在类型精确的函数内部

有时由于语言能力限制,必须使用类型断言。

可以将这段逻辑包装在一个函数内部,只要保证对外的接口类型是准确的,那它的影响面是可以接受的。(一般来说这种场景的代码改动频率较低)

不要使用基础类型的包装类型

基础类型 包装类型
string String
number Number
boolean Boolean
symbol Symbol
bigint BigInt

在 JS 中,基础值存在包装对象的概念。TS 为了兼容这一行为,每一种基础类型都有对应的包装类型。

基础类型可以赋值给对应的包装类型,但反过来不行。

实际使用中,几乎没有需要使用包装类型的场景。

为了一致性,请一律使用基础类型。

使用类型操作符和泛型避免重复

在编程中,重复代码会造成维护性问题,我们会通过提取变量,函数的方式避免重复。

TS 类型也是一样,需要尽量避免重复声明相同类型

  • 最常用的方法是给类型命名

对于确定一致的结构,应该使用命名类型来复用。这与将重复的字面量提取出来作为常量是一样的思路。

  • 既可以增强代码可读性(类型的命名可以体现其含义和用途)
  • 也可以方便修改所有引用的属性名,属性类型等

使用 extends 扩展命名类型得到新类型

使用 Pick 泛型从命名类型中裁剪得到新类型

  • 常用的工具泛型还有:
    • Omit
    • Partial
    • Required
    • Exclude
    • ReturnType

泛型就如同类型的函数,熟练掌握可以避免重复声明类型。

  • 使用类型操作符 typeof 可以从值获得类型

不过,从值生成类型时要小心。一般最好先定义类型,再声明值的类型。这样可以使类型更明确,不会被 TS 从值推断出的类型干扰。(下文会介绍自动推断的规则)

使用 Mapped Type 保证类型一致性

假设 React 组件有这样的 props,我们希望在除了 onClick 之外的 props 变化时才 rerender

  • props 里需要新增属性怎么办
    • 上述两种方案都无法保证达到预期效果
    • 在 props 类型里添加注释:“添加属性时需要修改 shouldUpdate”?

[k in keyof ScatterProps] 告诉 TS,REQUIRES_UPDATE 需要与 ScatterProps 有相同的 key,一旦它们不同步,TS 会报错。

其他需要保证一致性的场景下,也可以建立这样的类型关系,让 TS 帮助检查。

当 TS 能够推导出类型时,不要显式标注类型

在大多数时候,TS 能够根据上下文自动推导出变量的类型。额外的类型标注反而会影响代码的维护性。

当某一天,Product.id 从 number 改成 string 时,额外的类型标注会产生不必要的错误

  • 自动推导出的类型通常会比预期的更严格,但通常这不会造成问题

下文会说明 TS 自动推导的原则,以及如何修改推导出的类型

  • 回调函数的参数也可以自动推导,不需要显式标注
  • 理想情况下,对于一段函数来说,只有输入的参数和返回值需要显式标注类型,内部的中间变量大多数都可以自动推导出类型
  • 但某些情况下,即使能够推导出类型,也最好添加类型标注

1. 声明对象字面量时

2. 函数的返回值(特别是模块对外的公开函数)

  • 可以利用多余属性检查,检测出错误的属性定义
  • 尽早让 TS 在变量定义处提示错误,而不是在变量使用的地方报错
  • 可以验证函数预期的输出值,确保函数实现的变化不会导致输出变化
  • 可以让输出值的类型有更明确的语义,也更方便文档建设

不要使用同一个变量存储不同类型的值

TS 认为:一个变量的值可以改变,但是它的类型一般不应该改变

这同样是 TS 设计者的偏好,这样做一般不会有正面作用,是应该避免的做法。

思考:TS 为什么推导成 string,而不是更严格的 "12-34-56"?

  • 显式标注类型可以解决报错,但这样做并没有好处
    • ​​​​​​​​​​​​​在后续使用时,经常需要先判断类型才能使用
    • 给阅读代码添加负担
  • 实际上这两个值并没有必然的联系,更好的做法是使用不同的变量
  • 使用不同变量的做法优势在于:
    • 明确区分两个并无关联的概念 (ID 和序列号数字).
    • 可以对两个变量分别使用更确切的命名
    • 不再需要额外的类型标注
    • 单个变量的类型更简单 (分别是 stringnumber, 而不是 string|number).
    • 变量可以用 const 而不是 let 声明。使得代码阅读者以及 TS 类型检查更容易理解和追踪它的使用情况。(不变的总是更容易理解)

理解 Type Widening

为什么 TS 将 x 的类型推断为 string 而不是 "x"

  • 对一个值来说,它匹配的类型有无穷多种
  • ('x' | 1)[]
  • ['x', 1]
  • [string, number]
  • readonly [string, number]
  • (string|number)[]
  • readonly (string|number)[]
  • [any, any]
  • any[]
  • TS 会根据上下文尽可能地推断开发者的意图,从而确定一个类型(上面的例子是 (string|number)[]
    • TS 会按照常规的编程习惯,选择合适的类型(在精确性和灵活性间取得平衡)
    • 但 TS 不会读心术,总会有不符合开发者预期的时候
    • 通过某些方式可以控制 TS 推断的结果
  • const 声明变量会推断出更精确的类型
  • const 声明对象字面量等价于用 let 声明每个属性

v.x 的类型与 let x = 1 的类型相同

  • 可以显式标注更精确的类型
  • 可以使用 as const  显式地获得最精确的类型

理解类型收窄(Type Narrowing)

  • 类型收窄是指 TS 根据上下文将变量类型推断为更精确类型的过程

TS 通过 if (el) 分析出两个分支下的类型可以更精确

  • 需要注意,TS 无法分析出多个指向同一个值的变量

尽量使用单个变量访问同个值可以帮助 TS 追踪类型

  • throw, return, break, continue 等流程控制操作也可用于 if 判断结合使用,收窄类型
  • 值空间的 instanceof 操作符,可以让类型收窄为该类的实例

typeof 的作用类似,常用于收窄为 string, number 等基本类型

  • in 操作符进行属性检查也可以收窄为具体类型
  • Array.isArray 可以让变量收窄为 Array 类型
  • 给多个可能的类型加上统一的字符串字面量属性作为 "tag",可以根据 "tag" 值收窄类型

这种做法叫做 "tagged union"

  • TS 提供了一种语法,可以自定义 type guard

只要 isInputElement 返回 truthy 值,el 类型就会被 TS 收窄为 HTMLInputElement

EGS 提供的 type guard 工具函数

一次性初始化对象所有属性

类似地,在类的构造函数中要确保初始化所有声明的属性

始终要保证值与类型一致

让 TS 类型只能表达合法状态

如何理解 isLoading 为 true,error 又非空的状态?程序逻辑中要如何处理?

加载中时,isLoading 为 true,加载结束后 isLoading 为 false。当加载出错时,error 为错误信息,成功时 error 为空

当类型只能表达合法的状态,TS 通过类型验证业务状态和逻辑都是合法的。

出生地和初始日期要么都有,要么都没有。第二种定义更合理。

  • 对于数据生产者:可以确保产出的数据是符合预期的
  • 对于数据消费者:不需要考虑非预期的非法状态如何处理

尽可能精准地描述类型,并且保证值与类型一致,可以让 TS 类型检查发挥最大作用。

相信 TS 类型

  • TS 类型是编程的契约,如果项目内类型声明是可靠的,就完全不需要添加额外的运行时检查。
  • 如果代码中有很多这样的情况,可能是项目的类型设计出了问题
    • 这会大大降低 TS 的价值,提高开发时的心智负担,产生更多运行时错误
    • 要检查并修正,确保得值与类型一致,继而移除类似多余逻辑

给类型添加 "brand" 来模拟名义类型

给类型声明特有的属性,阻止外界轻易创建出兼容结构的数据,从而实现只接受来自特定实现的数据。

使用特殊属性名或值,例如用 symbol 作为值或属性可以有更强的保证

尽可能添加 private/readonly 修饰

  • private 可以控制接口的访问范围:
    • ​​​​​一个模块对外暴露的接口越少,修改起来越容易
    • 意外暴露的接口可能会被错误使用
    • 一旦被使用就很难再撤回
  • readonly 可以明确表达属性的不变性
    • 该属性的生命周期更清晰
    • 减轻阅读时更容易理解
    • 不变的值总是更容易进行缓存等优化手段

使用 dts-rollup 打包类型

提供公共包时,使用 dts-rollup 对编译出的 .d.ts 打包,可以控制对外暴露的接口,避免内部接口泄漏,影响项目后续维护。

更多最佳实践

公司内其他同事分析的最佳实践:

谢谢

TypeScript

By yiliang_wang

TypeScript

  • 28