聊聊前端中的强制类型语言:TypeScript。
最早我是通过编写 TSX 代码逐步学习 TS,这篇文章不会教你如何去学 TS,而是以若干知识点的形式,介绍相关概念,并给出我对 TS 的一些感悟和理解。
由于个人能力和经验的局限,里面的内容我不能保证完全正确,请读者阅读时保持批判思维,并以官网的文档为主。
1. unknown 和 any
TypeScript 中最基础的概念是原始类型,其中 unknown
和 any
是其中比较容易混淆的概念。
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
}
这里有两个类型:Duck1
和 Duck2
。它们结构上完全相同,只是类型名不同。在 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 的类型只能取这个范围内更加具体的类型,即 string
或 number
或 及其对应的相关字面量类型。
泛型约束的概念中还有一个值得关注的问题,什么时候需要写泛型:当泛型需要被内部逻辑使用 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