TypeScript 类型体操指北

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

一般指数字类型的总称,包含 intfloat

boolean

一般指 truefalse 的总称

复合类型

通用 K/V 结构

一般形如下述结构:

// interface
interface MyKVStructure {
    [key in string | number | symbol]: any;
}

// type alias
type MyKVStructure = {
    [key in string | number | symbol]: any;
}

显然 通用 K/V 结构 包含两个可供设置类型的「槽位」[^5],其中「键」类型只能为stringnumbersymbol,而「值」类型理论上可以为任何类型。

补充阅读: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;
}

各类型常见场景

基础类型常见场景

stringnumberboolean 这类基础类型常用于「声明变量」

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 存在两种「类型兼容」方式:SubtypeAssignment

... In TypeScript, there are two kinds of compatibility: subtype and assignment ...

Assignment 详细规则见下表:

粗略的概括来说:

  • 「同类型」符合使用 Subtype方式,规则为上述:基础类型使用默认包含关系,K/V 结构使用 结构化类型系统
  • 「非同类型」使用 Assignment

「同类型」间的 Subtype 的规则,显而易见,常见如: true 与 booleanCat 与 Animal ,但「非同类型」间的 assignment,并不直观,这里将「类型」类比作「集合」,方便理解/讲解。

常见类型与集合对应关系

常见子集 (string、number、boolean) 空集 (never) 全集 (unknown) 法外狂徒 (any),any 不属于类型系统,故官方也推荐使用 unknown 替代 any

常见类型「子集」关系示意图

注意:下述示意图仅针对同层

Ts与集合名词类比对照表

名词集合TypeScript
包含于 extends
交集 &
并集 |
空集 never
全集 U unknown

类型推导

基本运算

集合运算

 B

使用 extends 关键字 例:

type TestUnknown<T> = T extends unknown ? 'Y' : 'N';
type TestNever<T> = T extends never ? 'Y' : 'N';

// 任何集合都包含于全集
TestUnknown<boolean>; // Y
// 任何集合都不包含于空集
TestNever<boolean>; // N

 B

使用 & 符号

 ∅ = ∅

例:

// 任何集合与空集的交集都是空集
type Test = string & never; // never

 U = A

例:

// 任何集合与全集的交集都是自己
type Test = string & unknown; // string

常见运用:

  1. 利用 unknown 剔除「交叉类型」中某些不需要的类型
  2. 利用 never 实现「一着不慎,满盘皆输」的判断操作(例如 js 中 Array.prototype.some 的表现)
  3. 与 | 联合使用实现批量过滤

 B

使用 | 符号

 U = U

例:

// 任何集合与全集的并集都是全集
type Test = { b: number } | unknown; // unknown

 ∅ = A

例:

// 任何集合与空集的并集都是自己
type T1 = string | never; // { b: number }

常见运用:

  1. 剔除「联合类型」中某些不需要的类型,常见使用范例如:某些「内置范型操作符」:ExcludeExtractOmit 等
  2. 利用 unknown 实现「一着不慎,满盘皆输」的判断操作(例如 js 中 Array.prototype.some 的表现)
  3. 与 & 联合使用实现批量过滤

条件运算

在常见的编程语言中,我们常常使用 if ... else 或者「三元运算」来实现「条件运算」。

「三元运算」在常见编程语言中有一个固定的格式:

条件表达式 ? 表达式1 : 表达式2

在 TypeScript 类型系统中,我们同样使用类似「三元运算」的方式来实现「条件运算」,这被称为 Conditional Types

「条件表达式」本质上最终会返回 boolean 类型值,在 TypeScript 类型系统中,依然遵循此规则。

在 typescript 中常常使用 例:

type MyType = true extends boolean ? 'Y' : 'N'; // Y

循环/遍历

在常见的编程语言中,我们常常需要对 ArrayTuple 结构进行 循环/遍历,语言本身也内置了很多方法帮助我们完成这样的需求。但是在 TypeScript 类型系统 中,并不支持这样直接地 循环/遍历 像 ArrayTuple 这样的结构。

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 TypeConditional 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'

懒人伸手坐等法(手动狗头)

伸手党常见策略,去社区寻找对应工具库,或者等官方更新「范型操作符」,需要保持良好的心理素质并时刻关注官方/社区动态,熟悉常见 内置泛型操作符,如:ReturnTypeParameters 等。 使用场景:万能解法,适用 所有场景

例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 类型的 pushpop ?

在旧版本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 类型的 pushpop,那么如何实现一些其他常用方法如 concatreverseshiftunshift 等。

参考答案

实战运用

经过上述内容的学习,大部分同学一定有一个疑惑,类型体操在日常的开发中有没有实际的运用场景呢?当然在堆业务代码的时候,大部分情况下是用不上的,或者说也不推荐用,毕竟类型体操的代码可读性确实堪忧,不过在编写一些公共的工具类库时,却可以大显身手。

例:为了迎合 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"] 这样的一个 联合类型 。

要完成上述需求,我们需要:

  1. 递归地暴力枚举所有的 key 和 value
  2. 判断 value 如果是非嵌套类型,那么把对应的 key push 到上一次的 tuple 里,如果依然是嵌套类型,那么继续递归下去

Tips:这里为了简化代码,会用到一个作者自己封装的库 :

具体功能就是用ts的类型系统实现类似 js 原生的 api 的工具函数,有兴趣可以了解一下,这里我们会用到 push 功能来实现最终结果的收集。


getByKeyPathStr 方法就很简单了,我们只需要基于 getIn 方法,使用类似 join 的操作,把入参的 ["a"] | ["b", "c"] 转换为 "a" | "b.c" ,这里为了简化代码,还是用到了 todash 中的 join 方法。

总结:

  1. 这里用到了文章最初讲到的 递归、暴力枚举类型。
  2. 这里利用了联合类型的 非空、有序、唯一、裂开的特性。
  3. 上述代码为了利于讲解,去除了js逻辑部分,有兴趣的同学可以补齐。
  4. 上述代码中 getIngetByKeyPathStr 方法为了简化场景,只完成了入参部分的类型推导,有兴趣的同学可以补齐返回值部分。
  5. 入参类型推导其实还有缺陷,实际应为 ["a"] | ["b"] | ["b", "c"] ,但改动其实不大,有兴趣的同学可以修正一下。

Q&A

为什么同样是 declare 声明的类型,有时在全局生效,有些只能在局部生效?

  1. 首先确保你自定义的类型均已被 ts 加载
  2. 检查你所编写的内容属于「脚本」还是属于「模块」,常见区分方法为:使用了 importexport 关键字则为「模块」。详见「typescript handbook」#modules
  3. 如果是「脚本」,直接 declare 即可在全局生效,若为「模块」,则仅在局部生效

【接上】「模块」内如何声明/扩展全局类型?

declare global {
    interface String {
        // ...
    }
}

详见「typescript handbook」#global modifying module

【接上】「脚本」内如何引入其他类型?

// 如果是 类库
/// <reference types="someLib" />

// 如果是 自定义文件
/// <reference path="..." />

详见「typescript handbook」#reference types

如何扩展一个库/模块内部的类型?

详见官方文档

我是一个伸手党,我想拿来就用,除了官方内置的泛型操作符有没有其他现成好用的库?

为什么有些人觉得 ts 很难学/难用?

常见原因:

解决方案:

  1. 请阅读文档
  2. 请仔细阅读文档
  3. 请熟读并背诵文档

名词解释

  • 非空有序集合:非空:指每一项元素不为空;有序:指该列表保持有序;集合:指每一项元素唯一。
  • 槽位:插槽的位置,这里理解为「占位」,常见组词如:「占位符」,用此代指指定嵌套类型所在位置常见类型列举

posted on 2022-08-22 17:40  漫思  阅读(339)  评论(0编辑  收藏  举报

导航