复杂场景下的 typescript 类型锚定 (2) ----- 泛型与类型推导

4,142

作者:东墨

前言:在编写 typescript 应用的时候,有时候我们会希望复用或者构造一些特定结构的类型,这些类型只从 typescript 靠内建类型和 interface、class 比较难以表达,这时候我们就需要用到类型推导, 而讨论类型推导, 则离不开泛型推断(#infer), 本文我们只讨论泛型


上一篇


泛型


从形式上看, typescript 中的泛型如同大多数语言(不包括尚未实现的 Go :P)里的泛型:


// one constrain for array-like object
interface ArrayLike<T> {
    readonly length: number;
    readonly [n: number]: T;
}


上面的 ArrayLike 表达了类数组结构, 它代表的对象的特征是:


  1. 有只读的 length 字段;
  2. 其它字段必须是数字, 且每个字段对应的值类型必须为 T, 这里的 T 就是泛型标记


array-like 对象是 Javascript 中很古老的对象, 如声明为 function (...) {...} 格式的函数, 其内部的 arguments 变量就是典型的 array-like 对象.


泛型往往可以用在这些场景:


  • 保留字 interface
  • 保留字 type
  • 命名空间(#namespace) 中的 class(实际上此时它是一个 interface)
  • 运行时中的 class
  • 函数定义(包括 class 的构造函数)


接下来我们依次说明, 在这些场景中, 类型推导如何发挥它的威力.


基于泛型的类型推导


在利用泛型做类型推导时, 切记:


  1. 泛型服务于类型的静态分析, 不服务于 Javascript 运行时
  2. 在考虑类型推导时的逻辑推算, 应考虑"它在运行时会得到何种类型", 而应考虑"基于类型本身的特性会得到何种类型"


第 2 点可能有点难以理解, 我们先略过, 在下一篇, 我们会明白这句话的含义.


interface: T 与 keyof


全键可选化


上一篇层提到, 我们可以通过 keyof 提取一个 interface 的所有键名, 当引入泛型后, keyof 还可以做更有趣的事情:


/**
 * Make all properties in T optional
 */
type Partial<T> = {
    [P in keyof T]?: T[P];
};


Partial 的作用是: 对 T(T 需要是可被当做 interface 的类型), 求其所有的键名, 并依赖键名, 得到一个结构完全相同, 但其所有键都可选 的新 interface.


比如, 对与 Form, UninitForm 可以是它的全键可选项版本:


interface Form {
    name: string
    age: number
    sex: 'male' | 'female' | 'other'
}

type UninitForm = Partial<Form>


则 UninitForm 等价于:


{
    name?: string;
    age?: number;
    sex?: "male" | "female" | "other";
}


全键必需化


相反, 如果你已经有了 UninitForm, 则你可以得靠 Required 到它的全键必需化版本:


/**
 * Make all properties in T required
 */
type Required<T> = {
    [P in keyof T]-?: T[P];
};


interface UninitForm {
    name?: string;
    age?: number;
    sex?: "male" | "female" | "other";
}

type AllKeyRequriedForm = Required<UninitForm>


全键只读化


结合 readonly, 我们可以把一个 interface 里所有的键转化为只读


/**
 * Make all properties in T readonly
 */
type Readonly<T> = {
    readonly [P in keyof T]: T[P];
};


强大的 extends 三元推导


对于 js 而言, extends 是扩展类的保留字; 而在 typescript 中, 当 extends 出现的如下的场景时, 它意味着类型推导:


/**
 * Extract from T those types that are assignable to U
 */
type Extract<T, U> = T extends U ? T : never;


形如 T extends [DEP] ? [RESULT1] : [RESULT2] 的表达式, 是 typescript 中的一种类型推导式, 它的规则是:


若泛型 T 必须满足 [DEP] 的约束(即 T extends [DEP]true), 则表达式结果为 [RESULT1]; 反之表达式结果为 [RESULT2]:


  1. 当 [DEP] 是基本类型时, 如果 T 是对应的基本类型, 则 T extends [DEP]true, 反之为 false
  2. 当 [DEP] 是 interface/class 时, 如果 T 必须满足它的约束, 则 T extends [DEP]true, 反之为 false
  3. 当 [DEP] 是 void/never 时, 按基本类型处理
  4. 当 [DEP] 是 联合类型, 组成 [DEP] 的类型会依次代入 T 进行运算, 最终的结果是这些运算结果的联合类型
  5. 当 [DEP] 是 any, 则 T extends [DEP] 恒为 true


按照这些规则, 我们来分析一下这个 Exclude.


/**
 * Exclude from T those types that are assignable to U
 */
type Exclude<T, U> = T extends U ? never : T;


分析可知:


  • Exclude 中有两个必要的泛型标记 TU(因为它们都未提供默认泛型)
  • 如果 T 是联合类型, 则我们会得到 T 中除了 U 之外的所有类型


我们来应用一下 Exclude


type NoOne = Exclude<1 | 2 | 3, 1>  // NoOne = 2 | 3


另外, 为了说明上面的第 4 点, 我们可以写一个毫无用处的 NotRealExclude;


type NotRealExclude<T, U> = T extends U ? U : T;

type Orig = NotRealExclude<1 | 2 | 3, 1> // Orig = 1 | 2 | 3


由于 NotRealExclude 在 T 不符合 U 的时候返回 U, 而在 T 符合 U 的时候又返回 T, 最终的结果是: 组成 T 的所有类型又被重新组装了起来.


extends 乱炖


在了解了 extends 的基本用法后, 我们来看更多的例子:


挑选, 排除, 重组对象的键值


/**
 * From T, pick a set of properties whose keys are in the union K
 */
type Pick<T, K extends keyof T> = {
    [P in K]: T[P];
};


正如我们常用的 pick 函数 可以提取对象中特定的键值对, Pick 也可以提取 T 中特定的键值定义.


interface Person {
    name: string
    age: number
    sex?: "male" | "female" | "other";
}

/**
 * equivalent to { name: string }
 */
type SimplePersonInfo = Pick<Person, 'name'>


既然可以 Pick, 那也可以 Omit


/**
 * Construct a type with the properties of T except for those in type K.
 */
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;


/**
 * equivalent to { name: string, age: number }
 */
type SimplePersonInfo = Omit<Person, 'sex'>


我们知道, 在对象的索引中, in 关键字可以用于从联合类型中提取类型, 作为 interface 的键名, 比如:


type Ks = 'a' | 'b' | 'c'
type KObject = {
    [P in Ks]: any
}


则 KObject 可以包含 'a', 'b', 'c' 三种类型的键名.


结合 extends, 我们可以轻松地从一个 interface 构建具有同样类型的值的字典, 这就是 Record:


/**
 * Construct a type with a set of properties K of type T
 */
type Record<K extends keyof any, T> = {
    [P in K]: T;
};


// construct one family with 3 keys: parent, mom, child
type Family = Record<'parent' | 'mom' | 'child', Person>


这等价于


interface Family {
    parent: Person,
    mom: Person,
    child: Person
}


非 Null 化变量等


/**
 * Exclude null and undefined from T
 */
type NonNullable<T> = T extends null | undefined ? never : T;


总结


至此,


  1. 我们知道了如何利用泛型推出新的类型
  2. 我们知道了如何将泛型与 extends 三元组, 索引 in 表达式 结合, 对 interface 进行拆解、重组


但到目前为止, 我们处理的场景都限与非函数的 interface(class)/type, 对于函数的 interface, 我们能否进行一些特殊处理, 比如, 对一个已有的函数定义, 提取其第 2 个参数的参数类型? 对于下面这个 func, 我们能否提取出 arg2 的类型?


interface func () {
    (arg1: string, arg2: {
        bar: string
    }): void
}


下一篇我们会讨论, 如何使用 infer 关键字达成这一目标.


其它


基于泛型的类型推导, 理论上从 typescript 2.8 开始(实际上更早, 但 typescript 2.8/3.5 是具有里程碑意义的版本, 故以此划分)就可以实现了, 从 typescript 3.5, 官方内置了一些用于推导的的类型(#type)和接口(#interface), 这些是我们用于学习类型推导的良好案例. 本文用到的所有例子, 都来自于 typescript 内置的 lib.es5.d.ts.


注意 对于泛型, T 往往是用作泛型标记的第一个选择, T 之于泛型, 好比 foo 之于样例代码