likes
comments
collection
share

让你的 TypeScript 更加安全!

作者站长头像
站长
· 阅读数 50

本文会介绍 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…

转载自:https://juejin.cn/post/7254792161072660540
评论
请登录