强制类型语言

Aug 22, 2023 ⋅ 15 minBack

聊聊前端中的强制类型语言:TypeScript。

最早我是通过编写 TSX 代码逐步学习 TS,这篇文章不会教你如何去学 TS,而是以若干知识点的形式,介绍相关概念,并给出我对 TS 的一些感悟和理解。

由于个人能力和经验的局限,里面的内容我不能保证完全正确,请读者阅读时保持批判思维,并以官网的文档为主。


1. unknown 和 any

TypeScript 中最基础的概念是原始类型,其中 unknownany 是其中比较容易混淆的概念。

any 类型在 TS 中比较特殊,我们可以把它当作「任意类型」,所以对于是 any 类型的变量,它可以被赋值给任意类型的变量。而 unknown 类型代表「未知类型」,即在未来的某一时刻它会变为某一个确定的类型,所以它只可以被赋值给 unknown or any 类型。

// unknown 在未来某一时刻一定会有一个确定的类型
let unknownValue: unknown = 100
unknownValue = 'foo'
unknownValue = {
  foo: 'bar'
}
unknownValue = true
// const s1: boolean = unknownValue // 报错, unknown 只能赋值给 unknown 和 any 类型

let s: any = 'str'
let num: number = s // 不报错,因为 any 类型可以赋值给任何类型, 这是和 unknown 直接的区别

2. 类型层级

如果说原始类型是 TS 中最基础的概念,那么「类型层级」就是 TS 中最重要的概念。你只有明白类型层级后,才能对条件类型判断甚至 TS 有更深的理解。

首先给出相关结论:

  • 类型系统越底层就是越具体的类型,相反,类型系统越顶层则是越通用的类型
  • 类型系统从底层到顶层的路径:never => 字面量类型 => 基础类型 => 装箱类型 => unknown => any

我们可以把底层类型当作受限于顶层类型。


3. type 关键字

3.1 工具类型

在 TS 中,type 关键字主要目的:创建一个新的类型。如果,当 type 关键字接受一个「泛型」的时候,则表示当前的 type 类型别名变为了「工具类型」。

何为「工具类型」:基于泛型传入的类型进行一系列类型操作,从而得出一个新的类型。

比较常见的工具类型有:

type MyStringify<T> = {
  [P in keyof T]: string
}

type MyClone<T> = {
  [P in keyof T]: T[P]
}

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

type MyRequired<T> = {
  [P in keyof T]-?: T[P]
}

type MyPick<T, K extends keyof T> = {
  [P in K]: T[P]
}

3.2 type 类型合并

在「类型层级」中,我介绍了「越底层的类型就是越具体的类型」这一概念。在 type 中可以使用 & 合并多个类型,从而得到一个更加具体的类型。同理,interface 中可以使用 extends 来得到更加具体的类型。除此以外 interface 还支持对于同名的接口进行扩展。

type Union1 = (1 | 2 | 3) & (1 | 2)  // 1 | 2
type Union2 = (string | number | boolean) & string // string
type Union3 = (string | number | boolean) & null // never
type Union4 = (string | number | boolean) & unknown // (string | number | boolean)
type Union5 = (string | number | boolean) & any // any  特殊情况
type TCat = {
  name: string
  purrs: boolean
}

type TDog = {
  name: string
  barks: boolean
  wags: boolean
}

type CatOrDogOrBoth = TCat | TDog

type CatAndDog = TCat & TDog // 类型更具体

const catAndDog: CatAndDog = { // 一定要满足 TCat 和 TDog 中所有的属性
  name: 'foo',
  purrs: true,
  barks: true,
  wags: true,
}

interface ICat {
  name: string
  purrs: boolean
}

interface IDog {
  name: string
  barks: boolean
  wags: boolean
}

interface CatOrDogOrBoth2 extends ICat, IDog {} // 类型更具体

// 一定要满足 TCat 和 TDog 中所有的属性
const catAndDog2: CatOrDogOrBoth2 = {
  name: 'foo',
  purrs: true,
  barks: true,
  wags: true,
}

如上代码所示,类型表示的范围越小则类型越具体,而 & 就是为了缩小类型的范围,从而让类型更加的具体。

需要注意的,如果按照我们的理解:「& 操作符会让类型更加的具体、精准」,那么这里有个特殊情况,即类型名为Union5(string | number | boolean) 显然是要比 any 更具体的类型,为什么 & 后得到的类型是更通用的类型 any 呢?我能想到的解释是,any 类型比较特殊,我们把它看作一个通配符即可。


4. 鸭子类型

在 TS 中,「鸭子类型」也被称之为「结构化类型」。这是一个用来描述类型兼容性的关键概念。这个概念来自于“如果它看起来像鸭子,游泳像鸭子,叫声像鸭子,那么它就可能是一只鸭子”的说法。

首先看代码:

interface Duck1 {
  quack: () => void
  feathers: number
}

interface Duck2 {
  quack: () => void
  feathers: number
}

这里有两个类型:Duck1Duck2。它们结构上完全相同,只是类型名不同。在 TS 中,判断类型是否相同是以结构来进行判断,而不是类型名,即 TS 在做类型检查的时候,采用了鸭子类型的理念,通过结构来判断类型是否兼容,而不是通过显示的类型声明。所以,类型名 Duck1 和类型名 Duck2 本质上是同一个类型。

所以,对于多个拥有类型名不同但相同结构的类型,我们可以考虑只保留一个类型,否则会出现类型冗余的情况。

再看一段代码:

interface Duck {
  quack(): void
  feathers: number
}

let duck = {
  quack: () => console.log("Quack!"),
  feathers: 200,
  name: "Daffy"
}

let myDuck: Duck = duck; // 有效,因为 duck 具有所有 Duck 接口的必要属性

首先我们定义了一个接口 Duck ,里面有两个属性。其次,我们创建了一个 duck 变量,里面的属性除了实现了 Duck 接口还新增了一个 name 字段属性。对于 duck 变量可以直接赋值给类型为 Duck 的变量。这里是为什么呢?

我的理解:由于 TS 做类型检查的时候是遵循鸭子类型的理念,duck 变量已经包含了 Duck 接口的所有属性,TS 会去检查 duck 变量是否符合 Duck 接口的形状。由于 duck 对象有一个 quack 方法和一个 feathers 属性,它满足了 Duck 接口的要求,因此赋值操作是允许的。同时,也体现了“如果它看起来像鸭子,叫起来也像鸭子,那么它就可以被当作鸭子”。尽管 duck 对象没有显式声明它实现了 Duck 接口,但它的结构(即它有的方法和属性)与 Duck 接口匹配,所以在 TS 视角中,duck 就是一个 Duck 类型的实例。

由此我们可以得到一条推论:

  • 对于赋值语句而言,一个更具体的类型变量可以赋值给一个更通用的类型变量,即子类型变量可以赋值给父类型变量。(这里的原因就是在于 TS 做类型检查是遵循鸭子类型,一个更具体的类型肯定包含了更通用类型的所有方法和属性,而在做类型检查的时候由于子类型变量已经完全实现了父类型接口,所以赋值语句成立)

5. 条件类型和泛型约束

明白类型层级和鸭子类型后,条件类型和泛型约束都是比较简单的概念。

条件类型:

type TObj1 = {
  name: string
}

type TObj2 = {
  age: number
}

type TRes = TObj1 & TObj2 extends TObj1 ? true: false //  true

条件类型类似于三元运算符,只不过在 TS 中是通过 extends 关键字来判断,即 A extends B ,判断 A 是否为 B 的子类型,A 是否为更具体的类型。

上述代码中,TObj1 & TObj2 是更加具体类型,即 TObj1 & TObj2 会受限于 TObj1 类型。

泛型约束:

function handle<T extends string | number>(input: T): T {
  return input
}

上述代码中:T extends string | number 表明泛型 T 受限于 string | number ,T 的类型只能取这个范围内更加具体的类型,即 stringnumber 或 及其对应的相关字面量类型。

泛型约束的概念中还有一个值得关注的问题,什么时候需要写泛型:当泛型需要被内部逻辑使用 or 函数 return 出的内容和泛型相关的时候才需要写泛型。


6. infer 关键字

infer 关键字主要出现在「条件类型」中,主要用于 extends 子句中推断类型,捕获类型信息,从而省去了显式传递类型。

注意点:

  • 出现在条件类型语句中
  • 用于在条件类型的真分支中捕获类型
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T

type ReturnType = T extends (...args: any[]) => infer R ? R : never

async function getAsyncData() {
  return { key: 'value' }
}

type AsyncDataType = UnwrapPromise<ReturnType<typeof getAsyncData>> // { key: string; }

7. 分布式条件类型

分布式条件类型是条件类型在处理联合类型时,会自动分布到每个成员上。这里会有一点反逻辑的地方🥲

满足分布式条件类型的要点:

  • 进行类型创建时(比如用 type 关键字进行类型创建),且类型创建的过程中有「泛型参数」的引入
  • 传递的泛型参数是一个联合类型

具体看代码:

type R1 = (string | number | boolean) extends (string | number) ? true: false // false

type Condition<T> = T extends (string | number) ? T: never

type R2 = Condition<string | number | boolean> // string | number

根据上文的介绍,R1 类型为 false 是比较显而易见的,原因在于 string | number | boolean 并不受限于 string | number ,即 string | number | boolean 是一个更加通用的类型,所以 R1 类型值为 false。

对于 R2 的类型推断来源于分布式条件类型,具体来说,,A | B | C extends U ? X : Y 会被处理为 (A extends U ? X : Y) | (B extends U ? X : Y) | (C extends U ? X : Y)。这个是公式是分布式条件类型才采用的,即需要满足泛型类型创建的同时还要满足传递泛型参数是联合类型。对于普通的联合条件类型判断则按照正常思维来处理。

分布式条件类型应用的场景如下:

  • 过滤类型:过滤某些不想要的类型
type NonNullable<T> = T extends null | undefined ? never : T

type Result = NonNullable<string | number | null | undefined> // string | number
  • 映射类型:对于某些类型都进行某些特殊处理
type Promisify<T> = T extends any ? Promise<T> : never

type AsyncResult = Promisify<string | number> // Promise<string> | Promise<number>
  • 很多包含联合类型的工具类型中都有分布式条件类型的理论

如何阻止 TypeScript 进行分布式条件类型的处理呢?可以使用包装类型进行处理,例如用 [] 来包裹联合类型,从而阻止分布式条件类型的处理。具体例子如下:

type Naked<T> = T extends boolean ? "Y" : "N"

type Wrapped<T> = [T] extends [boolean] ? "Y" : "N"

type Res1 = Naked<number | boolean> // "N" | "Y"

type Res2 = Wrapped<number | boolean> // "N"

8. 类

在 TS 中,类的处理主要有两点需要注意:

  • 类中的公共方法需要对外暴露。我一般都会写一个具体的接口,里面包含了需要暴露的公共方法,让类去实现这个接口。

  • 类中的属性进行声明,并用相关的修饰符进行限定。


Wrapping Up

以上就是我个人认为关于 TS 这门强制类型语言所包含的一些基础概念和核心理念。

当然,这篇文章主要是自己用来梳理一遍 TS 相关知识点。如果这篇文章同时能帮助到你,对你有所启发,那就是再好不过的啦。

如果你想快速启动一个 TS 项目,你也可以使用我写的 TS 启动模版 😊:

  • pnpm create @lesenelir/ts-starter
  • yarn create @lesenelir/ts-starter
  • npm create @lesenelir/ts-starter@latest

[本文谢绝一切转载,谢谢]

Lesenelir