TypeScript 类型体操指北
TypeScript 类型体操指北
最近公司组织技术分享,作为一个懒癌患者,终于可以更新一下了
前言
- 「类型体操」一词,最早出现在 2006 年
Haskell
的文档。 - 本文需要读者有一定的
TypeScript
使用经验、熟悉TypeScript
官方文档。 - 本文旨在帮助开发者深入理解
TypeScript
类型系统,并灵活运用「类型体操」实现各种各样类型变换的需求。文中将类型类比做集合,帮助初学者更好地理解「类型体操」底层逻辑。但类型本质上并不等价于集合,在某些细节上必然存在出入,需要开发者根据实际情况自行甄别。如发现内容有误,欢迎指正。 - 本文定位是「手册」,贯彻「即插即用」「看的到抄的走」的理念,故不会涉及太多理论性内容的讲解。
- 目前各大知识分享社区关于「类型体操」已经很多优秀的文章,如:TypeScript 类型体操天花板,用类型运算写一个 Lisp 解释器、使用TypeScript类型体操实现一个简易版扫雷游戏 等。但这些文章存在一定的阅读门槛,也很难「抄的走」,故本文期望能降低门槛,补齐知识盲区,梳理知识结构,让更多人有志之士能掌握这一技能。
- 类型体操虽好,可不能贪杯啊!(具体业务场景中是否适用,还请自行斟酌)
- 本文是【万字长文】深入理解 TypeScript 高级用法 内容补充,如发现部分内容理解困难,可与上述文章一起食用。
常见类型概览
常见类型列举
基础类型
string
一般指是字符串类型的总称例如:"Hello, world"
模板字符串类型
模板字符串类型 最早从 TS 4.1
版本开始出现,并在后续版本中不断增强。 一般为如下形式:
type World = "world";
type Greeting = `hello ${World}`;
number
一般指数字类型的总称,包含 int
、float
boolean
一般指 true
、false
的总称
复合类型
通用 K/V 结构
一般形如下述结构:
// interface
interface MyKVStructure {
[key in string | number | symbol]: any;
}
// type alias
type MyKVStructure = {
[key in string | number | symbol]: any;
}
显然 通用 K/V 结构 包含两个可供设置类型的「槽位」[^5],其中「键」类型只能为string
、number
、symbol
,而「值」类型理论上可以为任何类型。
补充阅读:Differences Between Type Aliases and Interfaces
Array、Tuple
Array
本质上只是特殊形式的 K/V 结构,常用声明方式为Array<string>
、或string[]
;Tuple
是特殊形式的数组,详见typescript handbook#Tuple Types
泛型
「泛型」一般指的是在程序编码中一些包含类型参数的类型 例:
// 这里的 T 就是类型参数
interface MyType<T> {
a: T
}
在某些简单的场景下,我们也可以把它作为「类型模板」来用
函数类型
常见的形如 (...args: P) => R
的结构 函数有如何几种常见声明方式:
type MyFunction = (...args: any[]) => void;
interface MyFunction {
(...args: any[]): void;
}
type MyFunction = {
(...args: any[]): void;
}
各类型常见场景
基础类型常见场景
string
、number
、boolean
这类基础类型常用于「声明变量」
Array、Tuple
Array
常见于数组相关类型的描述Tuple
作为Array
类型的「子集」在日常开发场景中并不多见,通常大家都会选择使用Array
类型来实现描述数组类型的描述Tuple
有一个专用场景:用于描述「函数参数」类型,故TS 4.0
版本以后新增了Labeled Tuple
功能支持 详见官方文档Array
提供了一个可供设置类型的槽位「槽位」[^5],如果设置多个,只能使用Union Type
,而Tuple
提供了多个可供设置类型的「槽位」[^5],可以清晰、有序、准确地描述一组/多组类型,故在进行多个「类型推导」时,常使用Tuple
存储 输入值/中转值。
Function
Function
类型常用于函数类型描述Function
类型有两个可以 设置类型 的「槽位」,分别为:「参数类型」、「返回值类型」。Function
类型的「参数类型」是「双向协变」的,而「返回值类型」是「协变」的,可以利用此特性实现类型逻辑的「反转」与「映射」。
结构化类型系统
读完上述内容后,部分读者会好奇为何文章要强调 K/V
结构,而非直接用 object
来代指。 实际上 TypeScript
遵循的是「结构化类型」规范,也就是人们常说的「鸭子类型」。 换言之,针对 K/V
结构类型,只要满足「给定结构兼容」,那么就可以判定二者兼容。
详见 typescript handbook structural-type-system
Subtype
与 Assignment
在官网的解释中,Typescript
存在两种「类型兼容」方式:Subtype
、Assignment
。
... In TypeScript, there are two kinds of compatibility:subtype
andassignment
...
Assignment
详细规则见下表:
粗略的概括来说:
- 「同类型」符合使用
Subtype
方式,规则为上述:基础类型使用默认包含关系,K/V
结构使用结构化类型系统
。 - 「非同类型」使用
Assignment
。
「同类型」间的 Subtype
的规则,显而易见,常见如: true
与 boolean
、Cat
与 Animal
,但「非同类型」间的 assignment
,并不直观,这里将「类型」类比作「集合」,方便理解/讲解。
常见类型与集合对应关系
常见子集 (string、number、boolean) 空集 (never) 全集 (unknown) 法外狂徒 (any),any 不属于类型系统,故官方也推荐使用 unknown 替代 any
常见类型「子集」关系示意图
注意:下述示意图仅针对同层
Ts与集合名词类比对照表
名词 | 集合 | TypeScript |
---|---|---|
包含于 | ⊆ | extends |
交集 | ∩ | & |
并集 | ∪ | | |
空集 | ∅ | never |
全集 | U | unknown |
类型推导
基本运算
集合运算
A ⊆
B
使用 extends
关键字 例:
type TestUnknown<T> = T extends unknown ? 'Y' : 'N';
type TestNever<T> = T extends never ? 'Y' : 'N';
// 任何集合都包含于全集
TestUnknown<boolean>; // Y
// 任何集合都不包含于空集
TestNever<boolean>; // N
A ∩
B
使用 &
符号
A ∩
∅ = ∅
例:
// 任何集合与空集的交集都是空集
type Test = string & never; // never
A ∩
U = A
例:
// 任何集合与全集的交集都是自己
type Test = string & unknown; // string
常见运用:
- 利用
unknown
剔除「交叉类型」中某些不需要的类型 - 利用 never 实现「一着不慎,满盘皆输」的判断操作(例如 js 中 Array.prototype.some 的表现)
- 与
|
联合使用实现批量过滤
A ∪
B
使用 |
符号
A ∪
U = U
例:
// 任何集合与全集的并集都是全集
type Test = { b: number } | unknown; // unknown
A ∪
∅ = A
例:
// 任何集合与空集的并集都是自己
type T1 = string | never; // { b: number }
常见运用:
- 剔除「联合类型」中某些不需要的类型,常见使用范例如:某些「内置范型操作符」:
Exclude
、Extract
、Omit
等 - 利用
unknown
实现「一着不慎,满盘皆输」的判断操作(例如js
中Array.prototype.some
的表现) - 与
&
联合使用实现批量过滤
条件运算
在常见的编程语言中,我们常常使用 if ... else
或者「三元运算」来实现「条件运算」。
「三元运算」在常见编程语言中有一个固定的格式:
条件表达式 ? 表达式1 : 表达式2
在 TypeScript
类型系统中,我们同样使用类似「三元运算」的方式来实现「条件运算」,这被称为 Conditional Types
「条件表达式」本质上最终会返回 boolean
类型值,在 TypeScript
类型系统中,依然遵循此规则。
在 typescript
中常常使用 例:
type MyType = true extends boolean ? 'Y' : 'N'; // Y
循环/遍历
在常见的编程语言中,我们常常需要对 Array
、Tuple
结构进行 循环/遍历,语言本身也内置了很多方法帮助我们完成这样的需求。但是在 TypeScript
类型系统 中,并不支持这样直接地 循环/遍历 像 Array
、Tuple
这样的结构。
Union Type
的 古怪
表现
type MapType<T> = { a: T };
type MapTypeByConditionalType<T> = T extends any ? { a: T } : never;
type TT = MapType<string | number>; // { a: string | number; }
type TT = MapTypeByConditionalType<string | number>; // { a: string } | { a: number }
我们发现 Union Type
在经过某些 泛型操作符
后 "裂开了"
Union Type
更像是一个「非空有序集合」
下述为 type T = string | number
的 AST
描述:
可以看到 AST
中 Union Type
是以 Array
的形式存储的。
利用 Union Type
、Conditional Type
实现 循环/遍历
局部引用声明
infer
用于推导指定位置的类型 简单的来说,你可以把 Promise<inter A>
近似地理解为给 Promise
泛型参数传入了一个「类型引用A」,后续可以利用这个「引用A」拿到对应位置的类型。
递归运算
自引用递归
通过简单的词法表达即可完成自引用递归 例:
interface Cate {
id: string;
name: string;
children: Cate[];
}
常见运用:用于描述无限层级嵌套的结构,如:一级分类、二级分类
递归
可以利用「泛型操作符」即可实现简单的递归 常见的形如:
type MyType<A> = A extends B ? MyType<Operation<A>> : A;
常见运用:用于操作不定个数的类型「叠加操作」
常用技巧
常见泛型提取技巧
基本方法
这里都是官方提供的一些 “从类型中创建类型” 基本方法,有兴趣的同学可以点下方的链接了解一下
详见 「typescript handbook」#creating types from types
function
反向类型推导
// 提到泛型我们第一反应都是需要手动传递
function get<O, K, V>(obj: O, key: K): V;
// call
get<{ hello: number }, 'hello', number>({ hello: 100 }, 'hello'); // number
// 其实ts中,可以利用 function 的反向类型推导,动态根据入参的类型,推导出参数的类型
function get<O extends object, K extends keyof O>(obj: O, key: K): O[K];
// call
get({ hello: 'world' }, 'hello'); // string
“占位” 取类型法
使用 infer
关键字进行占位,然后将 infer 占位的类型返回
这里其实只要记住一句口诀:怎么放进去的,就怎么取出来
使用场景:常用于取出「指定位置」的泛型
例1: 快速取出 Promise
泛型
type DemoType = Promise<string>;
type ObtainPromiseResolveType<T> = T extends Promise<infer R> ? R : never;
type TT = ObtainPromiseResolveType<DemoType>; // string
例2: 快速取出 Array
泛型
type DemoType = Array<string>;
type ObtainPromiseResolveType<T> = T extends Array<infer R> ? R : never;
type TT = ObtainPromiseResolveType<DemoType>; // string
“索引” 暴力取类型法
利用「索引」暴力枚举所有对应的「值类型」,「索引」常使用 keyof
关键字生成。 使用场景:需要暴力枚举所有「值类型」的场景
例1: 快速取出 Array
泛型
type TT = DemoType[any]; // string
// 或者
type TT = DemoType[number]; // string
例2: 快速取出 K/V
结构中所有 V
的类型
interface MyKVObj {
a: string;
b: number;
c: boolean;
}
type ObjVals = MyKVObj[keyof MyKVObj] // string | number | boolean
递归 “叠加” 法
借鉴 「动态规划」 的思想,将一个复杂的需求拆解为多个简单相似的小需求,并用递归的方式实现 注意:由于 typescript
语言本身的限制,递归层级为有限次,超出上限会报错
例: 实现字符串类型的 trimStart
操作
type TrimStart<T extends string> = T extends ` ${infer Rest}` ? StringTrimStart<Rest> : T;
type TT = TrimStart<' Vue React Angular'>; // 'Vue React Angular'
懒人伸手坐等法(手动狗头)
伸手党常见策略,去社区寻找对应工具库,或者等官方更新「范型操作符」,需要保持良好的心理素质并时刻关注官方/社区动态,熟悉常见 内置泛型操作符
,如:ReturnType
、Parameters
等。 使用场景:万能解法,适用 所有场景
例1: 快速取出 Promise
泛型
// TypeScript 4.5 以后新增
type TT = Awaited<DemoType>; // string
例2: 如何实现首字母大小写转换
// 首字母大写
type TT = Capitalize<'react'>; // 'React'
// 首字母小写
type TT = Uncapitalize<'React'>; // 'react'
常见关系转化
A | B => A & B
A | B => [A, B]
[A, B] => A | B
[A, B] => A & B
A | B | C ... 依次取出末尾项
例题分析
例1:如何实现 TypeScript
中 Tuple
类型的 push
、pop
?
在旧版本ts(3.x)中,实现起来会很麻烦,但在最新版本的 ts(^4.2) 中,我们可以借助 Leading/Middle Rest Elements 语法可以很容易地实现
type Pop<T extends any[]> = T extends [...any[], infer Tail] ? Tail : never;
type TupleByPop<T extends any[]> = T extends [...infer Head, any] ? Head : [];
例2:LeetCode-OpenSource/hire
我们来看一道 leetcode
的 ts
面试题 LeetCode-OpenSource/hire
课后思考
我们刚刚实现了 TypeScript
中 Tuple
类型的 push
、pop
,那么如何实现一些其他常用方法如 concat
、reverse
、shift
、unshift
等。
实战运用
经过上述内容的学习,大部分同学一定有一个疑惑,类型体操在日常的开发中有没有实际的运用场景呢?当然在堆业务代码的时候,大部分情况下是用不上的,或者说也不推荐用,毕竟类型体操的代码可读性确实堪忧,不过在编写一些公共的工具类库时,却可以大显身手。
例:为了迎合 react
政治正确,我们通常会有数据 不可变
的需求,在 immer
这个库出现之前,我们一般会使用 immutable
这个库。
这个库的类型推导其实很不好用,出/入参都没有很好的类型校验。
import { fromJS } from "immutable";
const testObj = {
hello: 'world',
hello2: 'world',
nested: {
a: '100',
b: 100,
c: false,
nested2: {
d: '100',
e: 100,
f: false,
}
}
}
// immutable demo
const immutableObj = fromJS(testObj);
immutableObj.get(''); // unknown;
immutableObj.getIn(['nested', 'a']); // unknown
那么整活的时候到了:如何实现一个有类型推导的 immutable
?
废话少说先上代码:
import { TupleJoin, TupleByUnionPush, EnsureArray } from "todash";
type BaseType =
| string
| number
| boolean
| undefined
| null
type GetKeyPath<Obj extends Record<string, any>, Result extends any[] = []> = {
[Key in keyof Obj]: Key extends string ? (Obj[Key] extends BaseType ? TupleByUnionPush<Result, Key> : GetKeyPath<Obj[Key], EnsureArray<TupleByUnionPush<Result, Key>>>) : never
}[keyof Obj];
// my immutable ts
function myFromJS<T extends Record<string, unknown>>(obj: T) {
return {
get<K extends keyof T>(key: K) {
return obj[key];
},
getIn<K extends GetKeyPath<T>>(key: K) /** TODO */ {
// TODO ...
},
getByKeyPathStr<KeyPathStr extends TupleJoin<GetKeyPath<T>, '.'>>(keyPathStr: KeyPathStr) /** TODO */ {
// TODO ...
}
}
}
// usage
// translate to immutable
const myImmutableObj = myFromJS(testObj);
// immutable get
const myValByGet = myImmutableObj.get('nested');
// immutable nested getIn
myImmutableObj.getIn(['nested', "a"]);
// immutable nested getByKeyPathStr
myImmutableObj.getByKeyPathStr('nested.nested2.f');
我们来看一下效果:
先来看一下 get
:
再来看一下 getIn
的入参校验:
最后为了增加难度,我们再额外提供一个 getByKeyPathStr
方法,允许直接输入路径字符串,如:"a.b.c"
。我们来看一下效果:
解题思路:
get
方法比较简单,直接使用 function
的反向类型推导,根据入参 K
,取出返回值 T[K]
的类型
getIn
方法比 get
方法稍复杂一些,我们需要一个包含所有 key 的组合 联合类型
。
例如:
interface Obj {
a: string;
b: {
c: number
}
}
上述这样一个结构,我们希望生成:["a"] | ["b", "c"]
这样的一个 联合类型
。
要完成上述需求,我们需要:
- 递归地暴力枚举所有的 key 和 value
- 判断 value 如果是非嵌套类型,那么把对应的 key push 到上一次的 tuple 里,如果依然是嵌套类型,那么继续递归下去
Tips:这里为了简化代码,会用到一个作者自己封装的库 :
具体功能就是用ts的类型系统实现类似 js 原生的 api 的工具函数,有兴趣可以了解一下,这里我们会用到 push
功能来实现最终结果的收集。
getByKeyPathStr
方法就很简单了,我们只需要基于 getIn
方法,使用类似 join
的操作,把入参的 ["a"] | ["b", "c"]
转换为 "a" | "b.c"
,这里为了简化代码,还是用到了 todash 中的 join
方法。
总结:
- 这里用到了文章最初讲到的 递归、暴力枚举类型。
- 这里利用了联合类型的 非空、有序、唯一、裂开的特性。
- 上述代码为了利于讲解,去除了js逻辑部分,有兴趣的同学可以补齐。
- 上述代码中
getIn
、getByKeyPathStr
方法为了简化场景,只完成了入参部分的类型推导,有兴趣的同学可以补齐返回值部分。 - 入参类型推导其实还有缺陷,实际应为
["a"] | ["b"] | ["b", "c"]
,但改动其实不大,有兴趣的同学可以修正一下。
Q&A
为什么同样是 declare
声明的类型,有时在全局生效,有些只能在局部生效?
- 首先确保你自定义的类型均已被 ts 加载
- 检查你所编写的内容属于「脚本」还是属于「模块」,常见区分方法为:使用了
import
、export
关键字则为「模块」。详见「typescript handbook」#modules - 如果是「脚本」,直接
declare
即可在全局生效,若为「模块」,则仅在局部生效
【接上】「模块」内如何声明/扩展全局类型?
declare global {
interface String {
// ...
}
}
详见「typescript handbook」#global modifying module
【接上】「脚本」内如何引入其他类型?
// 如果是 类库
/// <reference types="someLib" />
// 如果是 自定义文件
/// <reference path="..." />
详见「typescript handbook」#reference types
如何扩展一个库/模块内部的类型?
我是一个伸手党,我想拿来就用,除了官方内置的泛型操作符有没有其他现成好用的库?
为什么有些人觉得 ts 很难学/难用?
常见原因:
解决方案:
- 请阅读文档
- 请仔细阅读文档
- 请熟读并背诵文档
名词解释
- 非空有序集合:非空:指每一项元素不为空;有序:指该列表保持有序;集合:指每一项元素唯一。
- 槽位:插槽的位置,这里理解为「占位」,常见组词如:「占位符」,用此代指指定嵌套类型所在位置常见类型列举