半小时快速上手 TypeScript 类型编程!(附手摸手实战案例)

Posted SHERlocked93

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了半小时快速上手 TypeScript 类型编程!(附手摸手实战案例)相关的知识,希望对你有一定的参考价值。

1. Why

在介绍什么叫 TypeScript 类型编程和为什么需要学习 TypeScript 类型编程之前,我们先看一个例子,这里例子里包含一个 promisify 的函数,这个函数用于将 NodeJS 中 callback style 的函数转换成 promise style 的函数。

import * as fs from "fs";
function promisify(fn) 
  return function(...args) 
    return new Promise((resolve, reject) => 
      fn(...args, (err, data) => 
        if(err) 
          return reject(err);
        
        resolve(data);
      );
    );
  


(async () => 
  let file = await promisify(fs.readFile)("./xxx.json");
)();

如果我们直接套用上述的代码,那么 file 的类型和 promisify(fs.readFile)(...)(...) 的类型也会丢失,也就是我们有两个目标:

  1. 我们需要知道 promisify(fs.readFile)(...) 这里能够接受的类型。

  2. 我们需要知道 let file = await ... 这里 file 的类型。

这个问题的答案在实战演练环节会结合本文的内容给出答案,如果你觉得这个问题简单得很,那么恭喜你,你已经具备本文将要介绍的大部分知识点。如何让类似于 promisify这样的函数保留类型信息是“体操”或者我称之为类型编程的意义所在。

2. 前言 (Preface)

最近在国内的前端圈流行一个名词“TS 体操”,简称为“TC”,体操这个词是从 Haskell 社区来的,本意就是高难度动作,关于“体操”能够实现到底多高难度的动作,可以参照下面这篇文章。

  1. https://www.zhihu.com/question/418792736/answer/1448121319[1]

不过笔者认为上述概念在前端圈可能比较小众、“体操”这个名字对于外行人来说相对难以与具体的行为对应起来、目前整个 TC 过程更像有趣的 brain teaser[2],所以笔者觉得 TC “体操”还是用 Type Computing 、Type Calculation 或者“类型编程”来记忆会比较好理解,这也容易与具体行为对应,本文在接下来的环节会用“类型编程”来取代“体操”说法。

3. 建模 (Modeling)

其实类型编程说白了就是写程序,这个程序接受类型作为输入,然后输出另一个类型,因此可以把它建模成写普通的程序,并按照一般计算机语言的组成部分对 TS 的类型相关语法进行归类。

4. 语法分类 (Grammar Classification)

首先我们看看基本的语言都有哪些语法结构,以 JS 为例,从 AST(抽象语法树)的角度来看[3],语法可以按照以下层级结构进行分类:

但是我们今天不会以这种从上到下的树状结构来整理和学习,这样子的学习曲线一开始会比较陡峭,所以作者并没有按照从上到下的顺序来整理,而是以学习普通语言的语法顺序来整理。

4.1 基本类型 (Basic Types)

类似于 JS 里面有基本类型,TypeScript 也有基本类型,这个相信大家都很清楚,TypeScript 的基本类型如下:

  • Boolean[4]

  • Number[5]

  • String[6]

  • Array[7]

  • Tuple[8] (TypeScript 独有)

  • Enum[9] (TypeScript 独有)

  • Unknown[10] (TypeScript 独有)

  • Any[11] (TypeScript 独有)

  • Void[12] (TypeScript 独有)

  • Null and Undefined[13]

  • Never[14] (TypeScript 独有)

  • Object[15]

任何复杂类型都是基本类型的组合,每个基本类型都可以有具体的枚举:

type A = 
    attrA: string,
    attrB: number,
    attrA: true, // Boolean 的枚举
    ...


4.2 函数 (Function)

类比 let func = (argA, argB, ...) => expression;

javascript 中有函数的概念,那么 TypeScript 的 Type-level programming(以下简称 TP) 相关语法中有没有函数的概念呢?答案是有的,带范型的类型就相当于函数。

// 函数定义
type B<T> = T & 
    attrB: "anthor value"


// 变量
class CCC 
...

type DDD = 
...


// 函数调用
type AnotherType = B<CCC>;
type YetAnotherType = B<DDD>;

其中  <T> 就相当于函数括弧和参数列表,= 后面的就相当于函数定义。或者按照这个思路你可以开始沉淀很多工具类 TC 函数了,例如

// 将所有属性变成可选的
type Optional<T> = 
  [key in keyof T]?: T[key];


// 将某些属性变成必选的
type MyRequired<T, K extends keyof T> = T &
  
    [key in K]-?: T[key];
  ;
  
// 例如我们有个实体
type App = 
  _id?: string;
  appId: string;
  name: string;
  description: string;
  ownerList: string[];
  createdAt?: number;
  updatedAt?: number;
;

// 我们在更新这个对象/类型的时候,有些 key 是必填的,有些 key 是选填的,这个时候就可以这样子生成我们需要的类型
type AppUpdatePayload = MyRequired<Optional<App>, '_id'>

上面这个例子又暴露了另外一个可以类比的概念,也就是函数的参数的类型可以用 <K extends keyof T> 这样的语法来表达。

TypeScript 函数的缺陷 (Defect)

目前下面这三个缺陷笔者还没有找到办法克服,聪明的你可以尝试看看有没有办法克服。

高版本才能支持递归

4.1.0 才支持递归

函数不能作为参数

在 JS 里面,函数可以作为另外一个函数的入参,例如:

function map(s, mapper)  return s.map(mapper) 
map([1, 2, 3], (t) => s);

但是在类型编程的“函数”里面,暂时没有相关语法能够实现将函数作为参数传入这种形式,正确来说,传入的参数只能作为静态值变量引用,不能作为可调用的函数。

type Map<T, Mapper> = 
  [k in keyof T]: Mapper<T[k]>; // 语法报错


支持闭包,但是没有办法修改闭包中的值

TypeScript 的“函数中”目前笔者没有找到相关语法可以替代

type ClosureValue = string;

type Map<T> = 
  [k in keyof T]: ClosureValue; // 笔者没有找到语法能够修改 ClosureValue


但是我们可以通过类似于函数式编程的概念,组合出新的类型。

type ClosureValue = string;

type Map<T> = 
  [k in keyof T]: ClosureValue & T[k]; // 笔者没有找到语法能够修改 ClosureValue


4.3 语句 (Statements)

在 TypeScript 中能够对应语句相关语法好像只有变量声明语句相关语法,在 TypeScript 中没有条件语句、循环语句函数、专属的函数声明语句(用下述的变量声明语句来承载)。

变量声明语句 (Variable Declaration)

类比:let a = Expression;

变量声明在上面的介绍已经介绍过,就是简单地通过 type ToDeclareType = Expresion 这样子的变量名加表达式的语法来实现,表达式有很多种类,我们接下来会详细到介绍到,

type ToDeclareType<T> = T extends (args: any) => PromiseLike<infer R> ? R : never; // 条件表达式/带三元运算符的条件表达式
type ToDeclareType = Omit<App>; // 函数调用表达式
type ToDeclareType<T>=  // 循环表达式
    [key in keyof T]: Omit<T[key], '_id'>


4.4 表达式 (Expressions)

带三元运算符的条件表达式 (IfExpression with ternary operator)

类比:a == b ? 'hello' : 'world';

我们在 JS 里面写“带三元运算符的条件表达式”的时候一般是 Condition ? ExpressionIfTrue : ExpressionIfFalse 这样的形式,在 TypeScript 中则可以用以下的语法来表示:

type TypeOfWhatPromiseReturn<T> = T extends (args: any) => PromiseLike<infer R> ? R : never;

其中 T extends (args: any) => PromiseLike<infer R> 就相当条件判断,R : never 就相当于为真时的表达式和为假时的表达式。

利用上述的三元表达式,我们可以扩展一下 ReturnType,让它支持异步函数和同步函数

async function hello(name: string): Promise<string> 
  return Promise.resolve(name);

// type CCC: string = ReturnType<typeof hello>; doesn't work
type MyReturnType<T extends (...args) => any> = T extends (
  ...args
) => PromiseLike<infer R>
  ? R
  : ReturnType<T>;
type CCC: string = MyReturnType<typeof hello>; // it works

函数调用/定义表达式 (CallExpression)

类比:call(a, b, c);

在上述“函数”环节已经介绍过

循环相关 (Loop Related)(Object.keys、Array.map等)

类比:for (let k in b) ...

循环实现思路 (Details Explained )

TypeScript 里面并没有完整的循环语法,循环是通过递归来实现的,下面是一个例子:

注意:递归只有在 TS 4.1.0 才支持

type IntSeq<N, S extends any[] = []> =
    S["length"] extends N ? S :
    IntSeq<N, [...S, S["length"]]>

理论上下面介绍的这些都是函数定义/表达式的一些例子,但是对于对象的遍历还是很常见,用于补全循环语句,值得单独拿出来讲一下。

对对象进行遍历 (Loop Object)
type AnyType = 
  [key: string]: any;
;
type OptionalString<T> = 
  [key in keyof T]?: string;
;
type CCC = OptionalString<AnyType>;

对数组(Tuple)进行遍历 (Loop Array/Tuple)
map

类比:Array.map

const a = ['123', 1, ];
type B = typeof a;
type Map<T> = 
  [k in keyof T]: T[k] extends (...args) => any ? 0 : 1;
;
type C = Map<B>;
type D = C[0];

reduce

类比:Array.reduce

const a = ['123', 1, ];
type B = typeof a;
type Reduce<T extends any[]> = T[number] extends (...arg: any[]) => any ? 1 : 0;
type C = Reduce<B>;

注意这里的 reduce 返回的是一个 Union 类型。

4.5 成员表达式 (Member Expression)

我们在 JS 中用例如 a.b.c 这样的成员表达式主要是因为我们知道了某个对象/变量的结构,然后想拿到其中某部分的值,在 TypeScript 中有个比较通用的方法,就是用 infer 语法,例如我们想拿到函数的某个参数就可以这么做:

function hello(a: any, b: string) 
  return b;

type getSecondParameter<T> = T extends (a: any, b: infer U) => any ? U : never;
type P = getSecondParameter<typeof hello>;

其中 T extends (a: any, b: infer U) => any 就是在表示结构,并拿其中某个部分。

当然其中 TypeScript 本身就有一些更加简单的语法

type A = 
  a: string;
  b: string;
;
type B = [string, string, boolean];
type C = A['a'];
type D = B[number];
type E = B[0];
// eslint-disable-next-line prettier/prettier
type Last<T extends any[]> = T extends [...infer _, infer L] ? L : never;
type F = Last<B>;

4.6 常见数据结构和操作 (Common Datastructures and Operations)

Set

集合数据结构可以用 Union 类型来替代

Add
type S = '1' | 2 | a;
S = S | 3;

Remove
type S = '1' | 2 | a;
S = Exclude<S, '1'>;

Has
type S = '1' | 2 | a;
type isInSet = 1 extends S ? true : false;

Interp
type SA = '1' | 2;
type SB = 2 | 3;
type interset = Extract<SA, SB>;

Diff
type SA = '1' | 2;
type SB = 2 | 3;
type diff = Exclude<SA, SB>;

Symmetric Diff
type SA = '1' | 2;
type SB = 2 | 3;
type sdiff = Exclude<SA, SB> | Exclude<SB, SA>;

ToInterpType
type A = 
  a: string;
  b: string;
;
type B = 
  b: string;
  c: string;
;
type ToInterpType<U> = (
  U extends any ? (arg: U) => any : never
) extends (arg: infer I) => void
  ? I
  : never;
type D = ToInterpType <A | B>;

ToArray

注意:递归只有在 TS 4.1.0 才支持

type Input = 1 | 2;
type UnionToInterp<U> = (
  U extends any ? (arg: U) => any : never
) extends (arg: infer I) => void
  ? I
  : never;
type ToArray<T> = UnionToInterp<(T extends any ? (t: T) => T : never)> extends (_: any) => infer W
  ? [...ToArray<Exclude<T, W>>, W]
  : [];
type Output = ToArray<Input>;

注意:这可能是 TS 的 bug 才使得这个功能成功,因为 :

type C = ((arg: any) => true) & ((arg: any) => false);
type D = C extends (arg: any) => infer R ? R : never; // false;

但在逻辑上,上述类型 C 应该是 never 才对,因为你找不到一个函数的返回永远是 true 又永远是 false。

Size
type Input = 1 | 2;
type Size = ToArray<Input>['length'];

Map/Object

Merge/Object.assign
type C = A & B;

Interp
interface A 
  a: string;
  b: string;
  c: string;

interface B 
  b: string;
  c: number;
  d: boolean;

type Interp<A, B> = 
  [KA in Extract<keyof A, keyof B>]: A[KA] | B[KA];
;
type AandB = Interp<A, B>;

Filter
type Input =  foo: number; bar?: string ;
type FilteredKeys<T> = 
  [P in keyof T]: T[P] extends number ? P : never;
[keyof T];
type Filter<T> = 
  [key in FilteredKeys<T>]: T[key];
;
type Output = Filter<Input>;

Array

成员访问
type B = [string, string, boolean];
type D = B[number];
type E = B[0];
// eslint-disable-next-line prettier/prettier
type Last<T extends any[]> = T extends [...infer _, infer L] ? L : never;
type F = Last<B>;
type G = B['length'];

Append
type Append<T extends any[], V> = [...T, V];

Pop
type Pop<T extends any[]> = T extends [...infer I, infer _] ? I : never

Dequeue
type Dequeue<T extends any[]> = T extends [infer _, ...infer I] ? I : never

Prepend
type Prepend<T extends any[], V> = [V, ...T];

Concat
type Concat<T extends any[], V extends any[] > = [...T, ...V];

Filter

注意:递归只有在 TS 4.1.0 才支持

type Filter<T extends any[]> = T extends [infer V, ...infer R]
  ? V extends number
    ? [V, ...Filter<R>]
    : Filter<R>
  : [];
type Input = [1, 2, string];
type Output = Filter<Input>;

Slice

注意:递归只有在 TS 4.1.0 才支持

注意:为了实现简单,这里 Slice 的用法和 Array.slice 用法不一样:N 表示剩余元素的个数。

type Input = [string, string, boolean];
type Slice<N extends number, T extends any[]> = T['length'] extends N
  ? T
  : T extends [infer _, ...infer U]
  ? Slice<N, U>
  : never;
type Out = Slice<2, Input>;

这里只用一层循环实现 Array.slice(s) 这种效果,实现 Array.slice(s, e) 涉及减法,比较麻烦,暂不在这里展开了。

4.7 运算符 (Operators)

注意:运算符的实现涉及递归,递归只有在 TS 4.1.0 才支持
注意:下面的运算符只能适用于整型
注意:原理依赖于递归、效率较低

基本原理 (Details Explained)

基本原理是通过 Array 的 length 属性来输出整型,如果要实现 * 法,请循环加法 N 次。。。

type IntSeq<N, S extends any[] = []> =
    S["length"] extends N ? S :
    IntSeq<N, [...S, S["length"]]>;

===

type IfEquals<X, Y, A = X, B = never> = (<T>() => T extends X ? 1 : 2) extends <
  T
>() => T extends Y ? 1 : 2
  ? A
  : B;

+

type NumericPlus<A extends Numeric, B extends Numeric> = [...IntSeq<A>, ...IntSeq<B>]["length"];

-

注意:减法结果不支持负数 ...

type NumericMinus<A extends Numeric, B extends Numeric> = _NumericMinus<B, A, []>;
type ToNumeric<T extends number> = T extends Numeric ? T : never;
type _NumericMinus<A extends Numeric, B extends Numeric, M extends any[]> = NumericPlus<A, ToNumeric<M["length"]>> extends B ? M["length"] : _NumericMinus<A, B, [...M, 0]>;

4.8 其他 (MISC)

inferface

有些同学可能会问 interface 语法属于上述的哪些范畴,除了 Declaration Merging[16],interface 的功能都可以用 type 来实现,interface 更像是语法糖,所以笔者并没有将 interface 来实现上述任意一个功能。

inteface A extends B 
    attrA: string


Utility Types

TypeScript 本身也提供了一些工具类型,例如取函数的参数列表有 Parameters 等,具体可以参照一下这个链接[17]

5. 实战演练 (Excercise)

Promisify

import * as fs from "fs";
function promisify(fn) 
  return function(...args: XXXX) 
    return new Promise<XXXX>((resolve, reject) => 
      fn(...args, (err, data) => 
        if(err) 
          return reject(err);
        
        resolve(data);
      );
    );
  

(async () => 
  let file = await promisify(fs.readFile)("./xxx.json");
)();

  1. 我们需要知道 promisify(fs.readFile)(...) 这里能够接受的类型。

  2. 我们需要 let file = await ... 这里 file 的类型。

答案

结合类型编程和新版本 TS,会比官方实现库更简洁、更具扩展性(只支持 5 个参数)  https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/util.promisify/implementation.d.ts[18]

import * as fs from "fs";
// 基于数据的基本操作 Last 和 Pop
type Last<T extends any[]> = T extends [...infer _, infer L] ? L : never;
type Pop<T extends any[]> = T extends [...infer I, infer _] ? I : never;
// 对数组进行操作
type GetParametersType<T extends (...args: any) => any> = Pop<Parameters<T>>;
type GetCallbackType<T extends (...args: any) => any> = Last<Parameters<T>>;
// 类似于成员变量取值
type GetCallbackReturnType<T extends (...args: any) => any> = GetCallbackType<T> extends (err: Error, data: infer R) => void ? R : any;
function promisify<T extends (...args: any) => any>(fn: T) 
  return function(...args: GetParametersType<T>) 
    return new Promise<GetCallbackReturnType<T>>((resolve, reject) => 
      fn(...args, (err, data) => 
        if(err) 
          return reject(err);
        
        resolve(data);
      );
    );
  

(async () => 
  let file = await promisify(fs.readFile)("./xxx.json");
)();

MyReturnType[19]

基本上就是成员表达式部分提到的通用的提取某个部分的实现方法(用 infer 关键字)

const fn = (v: boolean) => 
  if (v) return 1;
  else return 2;
;
type MyReturnType<F> = F extends (...args) => infer R ? R : never;
type a = MyReturnType<typeof fn>;

Readonly 2[20]

基本上就是 Merge 和遍历 Object

interface Todo 
  title: string;
  description: string;
  completed: boolean;

type MyReadonly2<T, KEYS extends keyof T> = T &
  
    readonly [k in KEYS]: T[k];
  ;
const todo: MyReadonly2<Todo, 'title' | 'description'> = 
  title: 'Hey',
  description: 'foobar',
  completed: false,
;
todo.title = 'Hello'; // Error: cannot reassign a readonly property
todo.description = 'barFoo'; // Error: cannot reassign a readonly property
todo.completed = true; // O

Type Lookup[21]

成员访问和三元表达式的应用

interface Cat 
  type: 'cat';
  breeds: 'Abyssinian' | 'Shorthair' | 'Curl' | 'Bengal';

interface Dog 
  type: 'dog';
  breeds: 'Hound' | 'Brittany' | 'Bulldog' | 'Boxer';
  color: 'brown' | 'white' | 'black';

type LookUp<T, K extends string> = T extends  type: string 
  ? T['type'] extends K
    ? T
    : never
  : never;
type MyDogType = LookUp<Cat | Dog, 'dog'>; // expected to be `Dog`

Get Required[22]

参照 Object 的 Filter 方法

type GetRequiredKeys<T> = 
  [key in keyof T]-?:  extends Pick<T, key> ? never : key;
[keyof T];
type GetRequired<T> = 
  [key in GetRequiredKeys<T>]: T[key];
;
type I = GetRequired< foo: number; bar?: string >; // expected to be  foo: number 

6. 想法 (Thoughts)

沉淀类型编程库 (Supplementary Utility Types)

除了 Utility Types 之外,添加通用的,易于理解的 TypeScript 工具类库,做 TS 届的 underscore。

Update: 发现已经有这样的库了:

  • https://github.com/piotrwitek/utility-types

  • https://github.com/sindresorhus/type-fest

直接用 JS  做类型编程 (Doing Type Computing in Plain TS)

即使按照本文的建模方式,由上面的归类可以看出,目前对比起现代的编程语言还是缺失挺多的关键能力。类型编程学习成本太高、像智力游戏的原因也是因为语法成分缺失、使用不直观的原因。为了使类型编程面向更广的受众,应当提供更友好的语法、更全面的语法,一个朴素的想法是在 compile time 运行的类似 JS 本身的语法(宏?)。

以下语法纯粹拍脑袋,例如:

type Test = 
    a: string

typecomp function Map(T, mapper) 
    for (let key of Object.keys(T)) 
        T[key] = mapper(T[key]);       
    

typecomp AnotherType = Map(Test, typecomp (T) => 
    if (T extends 'hello') 
        return number;
     else 
        return string;
    
);

如果有这样子直观的语法,笔者感觉会使得类型编程更容易上手。需要实现这样的效果,可能需要我们 fork TypeScript 的 repo,添加以上的功能,希望有能力的读者可以高质量地实现这个能力,效果好的话,还可以 merge 到源 TypeScript Repo 中,造福笔者这个时刻为类型编程苦恼的开发者。

7. Reference

  1. https://github.com/type-challenges/type-challenges

  2. https://www.zhihu.com/question/418792736/answer/1448121319

  3. https://github.com/piotrwitek/utility-types#requiredkeyst

参考资料

[1] https://www.zhihu.com/question/418792736/answer/1448121319

[2] 有趣的 brain teaser: https://github.com/type-challenges/type-challenges

[3] AST(抽象语法树)的角度来看: https://github.com/babel/babel/blob/main/packages/babel-types/src/definitions/core.js

[4] Boolean: https://www.typescriptlang.org/docs/handbook/basic-types.html#boolean

[5] Number: https://www.typescriptlang.org/docs/handbook/basic-types.html#number

[6] String: https://www.typescriptlang.org/docs/handbook/basic-types.html#string

[7] Array: https://www.typescriptlang.org/docs/handbook/basic-types.html#array

[8] Tuple: https://www.typescriptlang.org/docs/handbook/basic-types.html#tuple

[9] Enum: https://www.typescriptlang.org/docs/handbook/basic-types.html#enum

[10] Unknown: https://www.typescriptlang.org/docs/handbook/basic-types.html#unknown

[11] Any: https://www.typescriptlang.org/docs/handbook/basic-types.html#any

[12] Void: https://www.typescriptlang.org/docs/handbook/basic-types.html#void

[13] Null and Undefined: https://www.typescriptlang.org/docs/handbook/basic-types.html#null-and-undefined

[14] Never: https://www.typescriptlang.org/docs/handbook/basic-types.html#never

[15] Object: https://www.typescriptlang.org/docs/handbook/basic-types.html#object

[16] Declaration Merging: https://www.typescriptlang.org/docs/handbook/declaration-merging.html

[17] 这个链接: https://www.typescriptlang.org/docs/handbook/utility-types.html

[18] https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/util.promisify/implementation.d.ts

[19] https://github.com/type-challenges/type-challenges/blob/master/questions/2-medium-return-type/README.md

[20] https://github.com/type-challenges/type-challenges/blob/master/questions/8-medium-readonly-2/README.md

[21] https://github.com/type-challenges/type-challenges/blob/master/questions/62-medium-type-lookup/README.md

[22] https://github.com/type-challenges/type-challenges/blob/master/questions/57-hard-get-required/README.md

最后

如果你觉得这篇内容对你挺有启发,我想邀请你帮我三个小忙:

  1. 点个「在看」,让更多的人也能看到这篇内容(喜欢不点在看,都是耍流氓 -_-)

  2. 欢迎加我微信「qianyu443033099」拉你进技术群,长期交流学习...

  3. 关注公众号「前端下午茶」,持续为你推送精选好文,也可以加我为好友,随时聊骚。

点个在看支持我吧,转发就更好了

以上是关于半小时快速上手 TypeScript 类型编程!(附手摸手实战案例)的主要内容,如果未能解决你的问题,请参考以下文章

自动化快速上手--Python--if--while--for循环--每天半小时--超详解篇

前端学习笔记TypeScript 快速上手

前端学习笔记TypeScript 快速上手

前端学习笔记TypeScript 快速上手

TS快速上手- 初遇:Hello,TypeScript

TS快速上手初遇:Hello,TypeScript