让你的 TypeScript 更加安全!

1,098 阅读11分钟

本文会介绍 TypeScript 带来的好处,后面主要会介绍 TS 的类型系统,一些高级语法等,主要包括 条件类型、as const、逆变协变等

TypeScript 的好处

  1. 静态类型检查出更多错误,避免 JavaScript 的人肉类型推导器
  2. 记录参数会让代码可读性更高 jsdoc 也有类型
  3. TS 提供额外的 doc 提示
  4. 重构更加的安全,深有感触
  5. 提高生产力:代码智能补全、提示,代码跳转、展示引用、自动 import、自动 rename等

首先最开始的是介绍类型兼容性,这是 TypeScript 的关键问题,类型兼容性是基于结构子类型的。

父子类型

子类型是指在类型系统中,一个类型可以被看作是另一个类型的特殊情况。

换句话说,如果类型 A 是类型 B 的子类型,那么类型 A 的值可以被安全地赋值给类型 B

例如下面的例子中,Dog 类型可以安全的赋值给 Animal 类型,因为 Dog 类型是 Animal 的子类型

interface Animal {
  type: number
}

interface Dog extends Animal {
  wang(): void
}

let animal: Animal
let dog: Dog

animal = dog // ✅ok
dog = animal // ❌error! animal 实例上缺少属性 'wang'

子类型的属性比父类型的更多,更具体。

也可以说,子类型是父类型的超集,而父类型是子类型的子集,这个很绕,直觉上容易混淆。

例如下面的例子就很容易被混淆:

type Parent = "a" | "b" | "c";
type Child = "a" | "b";

let parent: Parent;
let child: Child;

// 兼容
parent = child

// 不兼容,因为 parent 可能为 c,而 c 无法 assign 给 "a" | "b"
child = parent

'a' | 'b' | 'c' 乍一看比 'a' | 'b' 的属性更多,那么 'a' | 'b' | 'c' 是 'a' | 'b' 的子类型吗?其实正相反,

'a' | 'b' | 'c' 是 'a' | 'b' 的父类型,因为前者包含的范围更广,而后者则更具体。

关键在于:子类型比父类型更加具体。

鸭子类型

前面我们说了父子类型,他们都是基于类型上的父子关系,在 TypeScript 中我们称为鸭子类型。

这也是 TypeScript 类型系统中最大的特性,仅关注对象身上的属性和方法,而不关注继承关系。

简而言之,满足了类型上的所有属性和方法就是它的子类型,不需要关注继承关系,也就是定义上的相同。

例如下面的例子中,Cat 是 Dog 的子类型

Class Cat{ miao, animal } 

Class Dog{ animal }

T extends {}

在写类型挑战时,大部分题目都会用到 extends 关键字,但是确实没有认真研究过它的作用。以及标题中这段代码在 T 为 object时始终返回 true 的原因也值得深究。

例如下面这些

实现 Exclude

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

实现 If

type If<C extends boolean, T, F> = C extends true ? T : F;

实现 OmitByType

type OmitByType<T, U> = {
   [P in keyof T as T[P] extends U ? never : P]: T[P] 
}

我们都使用了 extends 做条件类型的判断

例如 Exclude 的例子

如果 T 是 U 的子类型,那么结果是 never 否则是 T

联合类型也叫分布式条件类型,也就是说 当 T 是 'A' | ‘B' 时,会被拆分成 'A' extends U ? never : T | 'B' extends U ? never : T

再回到我们标题中的例子,如何解释 T extends {} 当 T 是对象时始终为 true

因为 {} 类型表示一个空对象类型,它允许包含任意属性。因此, {} 类型相当于一个宽泛的类型,可以说是所有对象类型的基类型。

as const

as const 把类型缩紧成字面量类型。

const 断言会告诉编译器,为表达式推断出最窄最特定的类型。

当我们定义对象的时候,如果我们希望对象是完全不被改变的常量,就可以使用 as const,把对象的所有键变为 readonly,缩紧成字面量类型,这样对象就完全不可更改了。

const a = {
  pc: '移动端',
  h5: 'H5'
} as const;

映射类型

[K in T] 称为映射类型,K 表示 T 中的字面量被一个个取出,类似于 for in

type Keys = 'a' | 'b'
type O = {
  [K in Keys]: string
}

条件类型

条件类型的语法类似于我们平时常用的三元表达式,它的基本语法如下(伪代码):

A === B ? 1 : 2;
A extends b ? 1 : 2;

但需要注意的是,条件类型中使用 extends 判断类型的兼容性,而非判断类型的全等性。这是因为在类型层面中,对于能够进行赋值操作的两个变量,我们并不需要它们的类型完全相等,只需要具有兼容性,而两个完全相同的类型,其 extends 自然也是成立的。

如果想要判断类型的全等性,可以实现一个 IsEqual 方法

type IsEqual<A, B> = ((<T>() => T extends A ? true : false) extends
   (<T>() => T  extends B ? true : false) 
    ? true 
    : false 
)

通过 IsEqual 方法再来判断是否 extends true or false

IsEqual<T, A> extends true ? true : false

条件类型绝大部分场景下会和泛型一起使用,我们知道,泛型参数的实际类型会在实际调用时才被填充,而条件类型在这一基础上,可以基于填充后的泛型参数做进一步的类型操作,比如这个例子:

type Test<T> = T extends string ? "string" : "other";

分布式条件类型

在前面我们也提到了分布式条件类型,听起来很高级,其实是条件类型的分布式特性,当条件类型满足一定情况下会执行的逻辑而已。

例如下面的例子

type Condition<T> = T extends 1 | 2 ? T : never;
// 1 | 2 
type Res = Condition<1 | 2 | 3 >;
// never
type Res2 = 1 | 2 | 3 extends 1 | 2 ? 1 | 2 : never

这个例子可能会让你有疑惑,看起来他们返回的结果应该是一样的,但是 Res 返回的是联合类型,而 Res2 返回的 never

这是因为触发了分布式特性,将联合类型拆开来,每个分支分别进行一次条件类型判断。

官方的解释是:对于属于裸类型参数的检查类型,条件类型会在实例化时期自动分发到联合类型上。

自动分发也就是下面这样,将联合类型拆开来

type Test<T> = T extends boolean ? 'A' : 'B'
// 'A' extends U ? never : T  | 'B' extends U ? never : T
type A = Test<'A' | 'B'>

而这里的裸类型参数,其实指的就是泛型参数是否完全裸露,简而言之裸类型就是未经过任何其他类型修饰或包装的类型。

而我们可以通过下面这些方式让参数不是裸类型参数

type NoDistribute<T> = T & {};
// Promise [] enum 

因为我们并不只需要利用裸类型参数来确保分布式特性能够触发,有时候我们也需要禁用掉分布式特性。

例如前面联合类型的场景,我们不希望进行分布式判断,想要直接判断两个联合类型的兼容性

type CompareUnion<T, U> = [T] extends [U] ? true : false;
type CompareRes = CompareUnion<1 | 2 | 3, 1 | 2 >; // false

通过讲参数和条件包裹的方式,我们对联合类型的比较就变成了数组成员类型的比较,在此时就会严格遵守类型层级一文中联合类型的类型判断了

在类型体操中,我们经常会遇到判断一个类型是否为 never 的问题,第一反应可能是

type IsNever<T> = T extends never ? true : fasle

但是这样是不可以的,因为 never 属于 bottom type,它是所有类型的基类型,当泛型参数为 never 时,会直接返回 never,跳过判断,因此上面的方式是不正确的。

我们需要用包裹的手段来处理

type IsNever<T> = [T] extends [never] ? true : false;

类型收窄

类型断言

// 类型断言 —— 不飘红,但执行时可能错误
(value as Number).toFixed(2)

类型守卫

// 2、类型守卫 —— 不飘红,且确保正常执行
if (typeof value === 'number') {
    // 推断出类型: number
    value.toFixed(2);
}

typeof、instanceof、in、=== 都可以做类型守卫

never

never 表示的是空类型,永不存在的类型。

有两种可能,一种是 throw error 一种是死循环,程序一直不会运行到返回的时候

// 异常
function err(msg: string): never {
  throw new Error(msg)
}

// 死循环
function loop (): never {
  while(true) {}
}

bottom type

never 可以表示任何类型的子类型,所以可以赋值给任何类型

let neverValue: never
let num = 4;
num = neverValue

unknown 可以理解为 全集,never 是空集,任何类型都包含了 never

null、undefined

null 和 undefined 也可以表示任何类型的子类型。但是和 never 不同,never 除了本身以外,没有任何类型是它的子类型,不能把值赋给 never 类型的除了 never 可以。

never 的使用

never 相关的讨论:www.zhihu.com/question/35…

  • 可以用来做类型保护,只有 never 才能赋值给 never,那么如果有一天值变成非 never 了,就会 ts 提示。
  • Unreachable code 检查:标记不可达代码,获得编译提示。
  • 类型运算:作为类型运算中的最小因子。
  • Exhaustive Check:为复合类型创造编译提示。

最小因子

T | never => T
T & never => never

可以用来简化类型运算

来自:blog.logrocket.com/when-to-use…

type NullOrUndefined = null | undefined
type NonNullable<T> = T extends NullOrUndefined ? never : T

// 运算过程
type NonNullable<string | null> 
  // 联合类型被分解成多个分支单独运算
  => (string extends NullOrUndefined ? never : string) |
   (null extends  NullOrUndefined ? never : null)
  // 多个分支得到结果,再次联合
  => string | never
  // never 在联合类型运算中被消解
  => string

React.FC

React.FC 在实际代码中没什么作用,但还会有很多缺点,具体可以看 github.com/facebook/cr…

逆变和协变

在 Typescript 中,类型系统提供了逆变和协变两种类型变换方式,以实现更加灵活的类型推导和组合。

逆变和协变都是术语,在其他的编程语言中也有类似的概念。

在网上官方的解释

协变是指:能够使用比原始指定的派生类型的派生程度更大(更具体的)的类型

逆变是指:能够使用比原始指定的派生类型的派生程度更小(不太具体的)的类型

简单来说:协变允许子类型转换为父类型,逆变允许父类型转换为子类型

逆变

逆变是指子类型关系随着类型参数的变化而相反的情况。换句话说,如果类型 A 是类型 B 的子类型,那么在逆变情况下,对于某个泛型类型 F,F<B>F<A> 的子类型。逆变通常出现在函数参数类型中。可以看下面的例子

type Callback<T> = (value: T) => void;

function contravariantFunction(callback: Callback<number>): void {
  callback(42);
}

const callback: Callback<object> = (value: object) => console.log(value);

contravariantFunction(callback); // 输出:42

在这个例子中,我们定义了一个泛型回调类型 Callback<T>,并创建了一个接受 Callback<number> 类型参数的函数 contravariantFunction。当我们将一个 Callback<object> 类型的回调传递给这个函数时,程序可以正常运行,并输出 42。

这是因为在逆变情况下,函数参数类型的子类型关系是相反的。在这个例子中,number 类型是 object 类型的子类型,因此 Callback<number> 类型是 Callback<object> 类型的子类型。

这意味着我们可以将 Callback<object> 类型的回调函数安全地传递给 contravariantFunction 函数。

这个要如何理解呢?我们用一个例子来说

假如有如下三种类型:Greyhound < Dog < Animal

问题:以下哪种类型是 Dog → Dog 的子类型呢?「Dog => Dog」 表示「参数为 Dog,返回值为 Dog 的函数」

  1. Greyhound → Greyhound
  2. Greyhound → Animal
  3. Animal → Animal
  4. Animal → Greyhound

我们从参数传入和返回两部分来看

对于参数传入部分:只能保证传入 Dog 类型参数,所以当我们定义 Animal 类型时,是可以保证的

对于返回部分:保证只使用 Dog 的方法或属性,那么类型 Greyhound 的方法 Dog 都有。

因此我们可以得到 (Animal -> Greyhound) ≼ (Dog -> Dog)

返回值类型很容易理解:Greyhound 是 Dog 的子类型。但参数类型则是相反的:Animal 是 Dog 的父类!

也称 参数是逆变的,返回值是协变的。

协变

协变是指子类型关系随着类型参数的变化而保持一致的情况。也就是说,如果类型 A 是类型 B 的子类型,那么对于某个泛型类型 F,F<A>F<B> 的子类型。协变通常出现在函数返回值类型和只读属性类型中。

type Producer<T> = () => T;

function covariantFunction(producer: Producer<object>): void {
  const value: object = producer();
  console.log(value);
}

const producer: Producer<number> = () => 42;

covariantFunction(producer); // 输出:42

在 TS 中协变逆变的概念很少见,因为 TypeScript 中只有函数的参数才是逆变的,仅有一处。

未完待续….

学习资料

exploringjs.com/tackling-ts… 电子书

github.com/sl1673495/b… 逆变协变

juejin.cn/post/699834…