hardfist/stackoverflow

typescript polymorphism: Generic

hardfist opened this issue · 3 comments

深入typescript类型系统: 泛型

https://zhuanlan.zhihu.com/p/82056426 前面讲了typescript关于子类型的一些类型设计,本文主要讲述关于泛型的一些类型设计。
泛型和子类型几乎是正交的两个概念,当然两者也可以配合使用(Bounded Polymorphism)。泛型可以说是Typescript类型系统里最难以理解的部分,因为其涉及非常多type theory的知识,本人对type theory也是一窍不通,只是结合平时的日常使用加以理解。

introduction

先看一个简单的

type constructor && opaque type alias

inheritance && subtyping

eager && defer type resolve

refinement type && liquid type

conditional type

variadic kinds

higher kinked type & monad

bounded parametric polymorphism

f-bounded polymorphism

recursive type

assignability

algebraic assignability

weaktype && apparent type

widening && fresh literal types

typescript 泛型和类型元编程

https://zhuanlan.zhihu.com/p/82056426 前面讲了typescript关于子类型的一些问题,本文主要讨论Typescript的泛型设计和类型元编程能力。泛型和子类型几乎是正交的两个概念,当然两者也可以配合使用(Bounded Polymorphism)。泛型可以说是Typescript类型系统里最难以理解的部分,因为其涉及非常多type theory的知识,本人对type theory也是一窍不通,只是结合平时的日常使用加以理解。

introduction

我们先实现一个简单的函数,用于查找数组的第一个元素

function firstElementString(list: string[]){
  return list[0];
}
function firstElementNumber(list: number[]){
  return list[0];
}

我们发现每次添加一个新的类型,我们都要重新实现一遍该函数,当然我们也可以直接使用any

function firstElement(list: any[]){
  return list[0];
}

但这样无法保证返回值的类型和传入参数类型的一致性,这时候使用泛型就比较合理

function firstElement<T>(list: T[]): T{
   return T[0]
}
const s = firstElement<string>(['a','b','c']) // s is string
const n = firstElement<number>([1,2,3]) // n is number

调用的时候也可以不指明返回类型,可以自动的根据实参推断出类型变量的类型

const n = firstElement([1,2,3]) // n is number

多个类型变量之间甚至可以建立约束关系

function pick<T>(o: T, keys: keyof T) {
    
}
pick({a:1,b:2},'c') // 报错,'c'不属于'a'|'b'

上面的firstElement对于任何的T类型都有效,但是有时候我们的函数实现依赖了类型变量的某些性质,这时候我们需要对类型变量加以约束,来保证我们实现的合法性。

function longest<T extends { length: number }>(a: T, b: T) {
  if (a.length >= b.length) {
    return a;
  } else {
    return b;
  }
}

如上述longest函数实现,其要求T类型必须有length属性,这样才可以进行length大小的比较。Typescript中可以通过extends对参数变量的类型加以限制。
值得注意的是当函数的返回值也是类型变量时,有些行为可能会出乎意料

function minimumLength<T extends { length: number }>(obj: T, minimum: number): T {
  if (obj.length >= minimum) {
    return obj;
  } else {
    return { length: minimum }; // 报错 Type '{ length: number; }' is not assignable to type 'T'.
  }
}

这里的 {length:minimum}虽然貌似符合{length: number}的约束,但是这里的返回类型实际上还有一个约束就是与输入的obj类型参数一致。因此如果obj的类型为 {length:number} & { name: string},这里的{length:minimum}明显不符合约束

function minimumLength<T extends { length: number }>(obj: T, minimum: number): T {
  if (obj.length >= minimum) {
    return obj;
  } else {
    return {...obj, length: minimum } // 这里能同时保证满足T和{length:number}的约束
  }
}

我们查看泛型函数的类型发现,其类型和普通的类型不一致,其类型里包含类型参数
image
实际上我们可以定义其类型如下

type Fn<T extends {length: number}> = (obj: T, minimum: number) => T

很不幸Typescript缺乏对Generic values的支持,没办法直接声明一个变量类型为泛型(https://github.com/microsoft/TypeScript/issues/17574)
image

这里的Fn即是type constructor

type constructor

在typescript里有两个东西功能重合度很大即type alias和interface,这两者实际上都扮演了type constructor的角色(两者有细微的语义差异,这里暂不讨论),后续的type constructor泛指 type alias和interface。type constructor扮演的角色实际上相当于函数的角色,只不过其参数是类型,可以称之为type的函数,其输入是type输出也是type,其甚至有类似if/else的控制结构,实际上type constructor结合extends|infer和对recursive的支持,其本身也近似图灵完全(https://github.com/Microsoft/TypeScript/issues/14833)。

type constructor和Typescript 本身的一些类型运算符实际上构成了type expression,其和js里的表达式基本上能构成对应关系,我们因此可以把我们的type expression当做函数程序一样进行运行求值,即我们可以进行type-level programming(很类似于c++的模板元编程)。参考SICP中对于语言的三个基本要素的描述
image

我们通过和普通的js程序进行对比,来展示Typescript 类型是否满足这个三个基本要素。

基本数值和literal type
'abc' | 'def', ; // type-level
'hello' // value-level

类型别名和变量
type Age = number;  // type-level
let age = 1 // value-level

union和基本运算
type ID = number | string ; 
let id = 1 + 2;

对象和record type
type Class = { teacher: string, room_no: string} 
let class = {teacher:'yj', room_no: 201}

复合过程
type MakePair<T,U> = [T,U]
const make_pair = (x,y) => [x,y];
type Id<T> = T; 
const id = x => x;

函数求值和泛型实例化
let pair = make_pair(1,2)
type StringNumberPair = MakePair<string,number>

条件表达式和谓词
let res = x === true ? 'true': 'false'
type Result = x extends true ? 'true' : 'false'

对象解构 和 extractType
const { name } = { name: 'yj'}

type NameType<T> = T extends { name: infer N } ? N : never;
type res = NameType<{name: 'yj'}>


递归类型和递归函数
type List<T> = {
   val: T,
   next: List<T>
} | null

function length<T>(list: List<T>){
  return list === null ? 0 : 1 + length(list.next);
}


map && filter && 遍历 & 查找

const res = [1,2,3].filter(x => x%2 ===0).map(x => 2*x)
type A = {
    0: 1,
    1: 2,
    2: '3',
    3: '4'
}
type Filtler<T extends Record<string,any>, Condition> = {
    [K in keyof T]: T[K] extends Condition ? T[K] : never
}[keyof T]
type B = Filtler<A, string> // 不支持内联写type function

通过对比我们发现Typescript已经满足了上述的三个基本要素,完全可以进行很灵活的面向类型编程。但是其仍然存在某些限制(如只支持递归,不支持 循环,不支持对number literal进行数学运算等),导致其相比于js编程仍然稍显麻烦。本文通过几个case展示TS类型编程中容易碰到的一些问题

Tuple

细心的用户可能会发现,虽然在Javascript中不存在tuple类型(定长异构数组),但是Typescript是有单独的Tuple类型的,其在函数式编程中的类型安全扮演了重要的角色。

const a = [1,'a','3'] as const // [1,'a','3'] tuple类型
const  a = [1,'a','3'] // (string|number)[]数组类型

Tuple类型的一种重要应用就是定长函数参数的类型实际上tuple类型。

function test(name:string, age: number, single: boolean) { true }

type parameters = Parameters<typeof test> // tuple类型 [string,number,boolean]

实际上面test函数也可以表达如下,这样可以清楚的看出来,实际上定参的函数参数实际上就是一个单参的tuple(很不幸,javascript不支持tuple。。。)

function  test2(...args: [string, number, boolean]) {
    return true
}
test2(1, 2, 3) // 报错
test2('a', 2, true);

接下来我们可以对tuple进行一些常规的运算

Head: 获取tuple的第一个元素

type Head<T extends any[]> = T[0]
type head = Head<Parameters<typeof test2>> // 结果为string

Length: 获取tuple的长度

借助lookup type可以轻松获取

type Length<T extends any[]> = T['length']

Tail: 除去第一个的后续元素

很自然的想到用infer

type Tail<T> = T extends (head: any, ...tail: infer U) ? U : never; 

很不幸Typescript在数组里目前并不支持这样写 ttps://github.com/microsoft/TypeScript/issues/25719,但是在函数参数里却支持(有点莫名其妙)

type Tail<A extends any[]> = 
  ((...args: A) => any) extends ((h: any, ...t: infer T) => any) ? T : never

last 获取最后一个元素

既然我们已经能获取到Tuple的长度了,很自然的想到下述方法

type Last<T> = T[Length<T> -1]

很不幸Typescript目前并不支持对number literal运算的支持microsoft/TypeScript#26382 , 因此我们没办法直接这样操纵,怎么实现呢,读者自己可以想想

conditional type

从上面的例子可以看出,类型运算大量的依赖于conditional type,下面研究下conditional type的一些性质
conditional type的定义如下

T extends U ? X : Y

为了方便后续讨论,各参数定义如下:

  • checkedType 被检测类型
  • extendsType 判断条件
  • X: trueType 检测条件为true的结果类型
  • Y: falseType 检测条件为false的结果类型
    上述type expression的意思为:如果T能够assignable(这里不是subtype的意思,assignable的问题又足够讲一篇了)给U那么结果为X,否则结果为Y,如果上述表达式里的T和U含有泛型参数,那么condition的结果就被defer了,否则改表达式的结果被resolve为X或者Y
    一个简单的运用如下
type TypeName<T> =
    T extends string ? "string" :
    T extends number ? "number" :
    T extends boolean ? "boolean" :
    T extends undefined ? "undefined" :
    T extends Function ? "function" :
    "object";

type T0 = TypeName<string>;  // "string"
type T1 = TypeName<"a">;  // "string"
type T2 = TypeName<true>;  // "boolean"
type T3 = TypeName<() => void>;  // "function"
type T4 = TypeName<string[]>;  // "object"

该表达式虽然简单,但实际上充满了各种edge case。
这里着重声明一点,虽然Typescript多处使用了extends关键词,但是实际上每处extends的意思不尽相同,更不要强行的将extends往java的继承上去靠,extends关键词一定程度上感觉是被Typescript滥用了。

distributive conditional types

在上面的conditional types里,如果我们的 checked type是 naked type那么 conditional types就被称为distributive conditional types。distributive conditional types具有如下性质

type F<T> = T extends U ? X : Y
type union_type = A | B | C
type a = F<union_type>
那么a的结果为 A extends U ? X :Y | B extends U ? X :Y | C extends U ? X : Y

如下例所示

type T10 = TypeName<string | (() => void)>;  // "string" | "function"
type T12 = TypeName<string | string[] | undefined>;  // "string" | "object" | "undefined"
type T11 = TypeName<string[] | number[]>;  // "object"
嵌套运算

并且如果这里的X也是包含T的表达式,即X = G<T>那么此时T在X的表达式也满足U的约束,这实际上促使我们可以进行conditional types的嵌套运算,如下例所示

type ContainName<T> = T extends { name: string } ? T : never;

type ContainAge<T> = T extends { age: number } ? T : never;

type a = { name: 'yj' } | { age: 20 } | { name: 'yj', age: 20 }

type res = ContainAge<ContainName<a>> // 结果为 {name: 'yj', age: 20}

naked type

我们注意到distributive conditional types实际上有三个前提条件

  • 必须是checked type
  • 必须是naked type
  • T实例化为union type
    首先考察第一点这里要求的必须要checkedType,如果T出现在extends type里并不会distributive
type Boxed<T> = T extends any ? { value: T } : never;
type Boxed2<T> = any extends T ? { value: T } : never;
type a = Boxed<'a' | 'b'>  // distributed {value: 'a'} | {value: 'b'} 
type b = Boxed2<'a'|'b'>  // non distributive { value: 'a' | 'b'}

第二点是要求必须要naked type,然而Typescript并没有说明啥是naked type, 我们大致还可以这个type没有被包裹在其他的复合结构里,如 array , record , function等。如我们可以通过将T包裹为[T]来破坏naked type

type Boxed3<T> = [T] extends any ? { value: T } : never;
type c = Boxed3<'a' | 'b'> // { value: 'a' | 'b'}

第三点是要求T实例化为一个union type,这点本来似乎没啥歧义,是不是union一看便知,然而这里的union type还包含了两个看着不像是union的type, any和boolean

type Check<T> = T extends true ? 'true' : 'false'

type d = Check<any> // 'true' | 'false'
type e = Check<boolean> // 'true' | 'false'

出乎意料的是这里的返回结果并不是true而是true|false, 原因就在于any和boolean都被视为了union type,这在我们类型编程中经常会造成影响,如何避免any被resolve为trueType和falseType呢?很简单,破坏前面两个条件即可。
这里还有一个坑就是,虽然unknown和any都贵为 top type,unknown却没被视为union,而且这是故意为之的(因为any的union特性经常导致一些意外的行为,所以可能提供一个不union的替代吧)。

why never

这里其实还有另一个坑就是never的处理

type Boxed<T> = T extends any ? { value: T } : never;
type res = Boxed<never> // 结果为never
type res2 = never extends any ? { value: never} : never; // 结果为 { value: never}

WTF?res2不就是将res泛型实例化的结果吗?为啥子还不一样呢
没办法,这实际上是支持distributive conditional type的必要条件, 主要原因在于never是union运算的幺元

A | never  = A;

考虑下述运算

type F<T> = T extends U ? X : Y
type F<A> = A extends U ? X : Y  // before
// A = A | never
type F<A> = type F<A|never> = A extends U ? X : Y  | never extends U ? X : Y // after

我们这这里要保证before和after恒等,那么就必须要保证 never extends U ? X : Y的结果也是union的幺元即never。
never其实还另有其他用处,我们打开ts的标准ts声明lib.es5.d.ts 看看标准里怎么运用conditional type的
Alt text

我们发现经常出现下述模式

type F<T> = T extends Condtion? Result | never; 

为啥子这里的falseType要用never呢。原因也和distributive conditional type有关。原因还是在于never是union运算的幺元。所以如果我们的conditional types是做某些过滤操作的话,通常合理的做法就是讲falsetype设置为never,这样可以保证一旦某些union的分支判断结果为falseType,就可以过滤掉该分支。如下例所示

type Diff<T, U> = T extends U ? never : T;  // Remove types from T that are assignable to U
type T30 = Diff<"a" | "b" | "c" | "d", "a" | "c" | "f">;  // "b" | "d"

type resolve && type check

还有一个需要注意的是Typescript类型系统也分为type resolve和type check两部分, type check的结果可能并不影响type infer的结果。考虑下述case

type F<T extends string> = T extends string ? 'string' : 'other'

type a = F<'1'> // 结果为 'string'
type b = F<1>  // 结果为 'other'
type c = b extends 'other' ? true : false; // 结果为 true

这里虽然约定了T是 extends string的,但是这个约束不像嵌套运算里的讲的约束,嵌套运算里的约束会影响后续 运算结果,而这里的T的约束,只进行type checker,并不影响运算结果。

towry commented

能否说下在开启--strictFunctionTypes 的情况下,怎么解决下面的(ts会报错的)写法:

class Base {
  handler?: (b: Base) => void;
  onSomeEvt(handler: (b: Base) => void) {
    this.handler = handler;
  }
}

class Child extends Base {
  init() {
    // 会报错,因为 handler 回调接受的是 Base 类型,虽然 Child 是 Base 的 Derived 类型,
    // 但是因为 Child 有一些基础类型没有的属性,所代码可能会报错。
    this.onSomeEvt((c: Child) => {
      c.work();
    });
  }
  work() {
    console.log("work");
  }
}

如果用范型的话,会报这样的错误:

V could be instantiated with an arbitrary type which could be unrelated to Base<V extends Base<any>>

update:

一个解决方法是 使用范型引用子类型。然后在父类型需要传递 this 的时候,先将 this 转换成 any

export default class VeForm<
  T extends Dictionary,
  A extends TreeModel<T> = TreeModel<T>,
  V extends VeForm<T, A, V> = VeForm<T, A, any>
> {
  options: VeFormOptions;
  relationManager: RelationManager;
  renderTree: A;
  hooks: {
    self: SyncHook<[V]>;
    relationManager: SyncHook<[RelationManager]>;
    emitRelationAction: SyncBailHook<
      [any, VeFormOptions["context"]],
      Promise<any>
    >;
    renderTree: SyncHook<[A]>;
  };
  // .......... other code.
  init() {
      // 不转为any的话,会报 Argument of type 'this' is not assignable to parameter of type 'V'.
      this.hooks.self.call(this as any);
  }
}

麻烦的是,在子类使用的时候,总要填上一大堆范型参数。

class CustomVeForm extends VeForm<ModelPayload, TreeModel<ModelPayload>, CustomVeForm> {
   beforeInit() {
       this.hooks.self.tap("PluginSelf", (childform) => {
           childform.hooks.renderTree.tap("PluginName", (tree) => {});
           // ... boring code.
       })
   }
}

Update(06-07):

上面 VeForm 的例子的 perfect 的写法:towry/n#138 (comment)

towry commented

😄 上面的第一个例子找到正确写法了,使用 this 类型可以很好的解决父类型返回子类型的需求。

class Base {
  handler?: <T extends this>(b: T) => void;
  onSomeEvt(handler: (b: this) => void) {
    this.handler = handler;
  }
}

class Child extends Base {
  init() {
    this.onSomeEvt((c: Child) => {
      c.work();
    });
  }
  work() {
    console.log("work");
  }
}

Playground Link