如何上手 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 這樣的函數保留類型信息是 “體操” 或者我稱之爲類型編程的意義所在。

  1. 前言 (Preface)

最近在國內的前端圈流行一個名詞 “TS 體操”,簡稱爲“TC”,體操這個詞是從 Haskell 社區來的,本意就是高難度動作,關於“體操” 能夠實現到底多高難度的動作,可以參照下面這篇文章。

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

不過筆者認爲上述概念在前端圈可能比較小衆、“體操” 這個名字對於外行人來說相對難以與具體的行爲對應起來、目前整個 TC 過程更像有趣的 brain teaser[2],所以筆者覺得 TC “體操”還是用 Type Computing 、Type Calculation 或者 “類型編程” 來記憶會比較好理解,這也容易與具體行爲對應,本文在接下來的環節會用 “類型編程” 來取代 “體操” 說法。

  1. 建模 (Modeling)

其實類型編程說白了就是寫程序,這個程序接受類型作爲輸入,然後輸出另一個類型,因此可以把它建模成寫普通的程序,並按照一般計算機語言的組成部分對 TS 的類型相關語法進行歸類。

  1. 語法分類 (Grammar Classification)

首先我們看看基本的語言都有哪些語法結構,以 JS 爲例,從 AST(抽象語法樹)的角度來看 [3],語法可以按照以下層級結構進行分類:

但是我們今天不會以這種從上到下的樹狀結構來整理和學習,這樣子的學習曲線一開始會比較陡峭,所以作者並沒有按照從上到下的順序來整理,而是以學習普通語言的語法順序來整理。

4.1 基本類型 (Basic Types)

類似於 JS 裏面有基本類型,TypeScript 也有基本類型,這個相信大家都很清楚,TypeScript 的基本類型如下:

任何複雜類型都是基本類型的組合,每個基本類型都可以有具體的枚舉:

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;
Intersection
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>;
ToIntersectionType
type A = {
  a: string;
  b: string;
};
type B = {
  b: string;
  c: string;
};
type ToIntersectionType<U> = (
  U extends any ? (arg: U) => any : never
) extends (arg: infer I) => void
  ? I
  : never;
type D = ToIntersectionType <A | B>;
ToArray

注意:遞歸只有在 TS 4.1.0 才支持

type Input = 1 | 2;
type UnionToIntersection<U> = (
  U extends any ? (arg: U) => any : never
) extends (arg: infer I) => void
  ? I
  : never;
type ToArray<T> = UnionToIntersection<(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;
Intersection
interface A {
  a: string;
  b: string;
  c: string;
}
interface B {
  b: string;
  c: number;
  d: boolean;
}
type Intersection<A, B> = {
  [KA in Extract<keyof A, keyof B>]: A[KA] | B[KA];
};
type AandB = Intersection<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]。

  1. 實戰演練 (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 }
  1. 想法 (Thoughts)

沉澱類型編程庫 (Supplementary Utility Types)

除了 Utility Types 之外,添加通用的,易於理解的 TypeScript 工具類庫,做 TS 屆的 underscore。

Update: 發現已經有這樣的庫了:

直接用 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 中,造福筆者這個時刻爲類型編程苦惱的開發者。

  1. 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

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/hhJb1BW97P3R-OsCoOE_EA