TypeScript 類型編程: 從基礎到編譯器實戰
作者簡介: 吳明亮,抖音前端團隊 Monorepo 工程化框架核心開發者。
Typescript 的類型編程可以理解爲一門有限的函數式編程語言。
本文假定讀者已經使用過 typescript 並且瞭解基礎的類型概念,不會介紹基礎概念,主要專注於介紹如何進行系統化的類型編程。示例主要來源於官網、類型挑戰倉庫以及日常開發。
一、類型編程基礎
既然稱作類型編程,那自然和普通編程語言一樣,用於類型變量定義語句、類型表達式、類型函數等等,本小結將詳細講述類型編程的一些基礎知識。
希望通過本文能夠幫助讀者更好的理解 TS 的類型,讓日常開發中的類型操作更加容易。
看大佬們用 ts 類型實現編譯器,看起來非常其實(確實厲害 =_=),不過理解這篇文章的思想後,讀者們也可以實現~文章最後一個示例實現了一個 簡易加法表達式求值器。
1. 類型變量定義
TS 定義類型的方式有多種:
-
使用 type。
-
使用 interface。
-
使用 class、enum 等等,其中 class、enum 可以既爲值,又爲類型。
TS 提供了大量的基礎類型,可以直接在定義類型變量時使用(關於基礎類型的詳細介紹可以查閱 TS 文檔):
-
基礎數據類型,比如 string、number、boolean、symbol、undefined 等等。
-
字面量類型,比如 '123',5 等
-
對象類型,比如 {a: string}
-
函數類型,比如 (a: string) => void
-
元組類型,比如 [1, 2, 3]
-
數組類型,比如 string[]
-
...
// 使用 type 定義類型變量,類型是一個字面亮類型 '123'
type TypeA = '123'
// 使用 interface 定義類型變量
interface TypeB {
a: string
}
// 將對象類型
// {
// b: number
// c: TypeA
// }
// 賦值給 TypeC
// TypeA 是上面定義的類型變量,可以直接使用
type TypeC = {
b: number
c: TypeA
}
// 類型變量可以直接賦值給另一個類型變量
type D = TypeB
// 將函數類型賦值給 E
type E = (a: string) => void;
基於 TS 的基礎類型以及 type
等關鍵字,就可以定義自定義的類型變量。
2. 類型操作符
ts 中也定義了大量類型操作,例如 &(對象類型合併)、|(聯合類型)等等,這些操作可以操作 TS 的類型。
2.1 & - 合併類型對象
& 合併多個類型對象的鍵到一個類型對象中。
type A = { a: number }
type B = { b: string }
type C = A & B;
// C 包含 A 和 B 定義的所有鍵
/**
* C = {
a: number;
b: string;
}
*/
const c: C = {
a: 1,
b: '1'
}
注意使用 & 時,兩個類型的鍵如果相同,但類型不同,會報錯:
type A = { a: number }
type B = { a: string }
type C = A & B;
/**
報錯:
Type 'number' is not assignable to type 'never'.(2322)
input.tsx(62, 3): The expected type comes from property 'a' which is declared here on type 'C'
(property) a: never
*/
const c: C = {
a: 1 // error
}
2.2 | - 聯合類型
|
將多個類型組成聯合類型:
type A = string | number;
type B = string;
此時類型 A 既可以是 string
又可以是 number
,類型 B 是類型 A 的子集,所有能賦值給類型 B 的值都可以賦值給類型 A。
2.3 keyof - 獲取對象類型的鍵
keyof
可以獲取某些對象類型的鍵:
interface People {
a: string;
b: string;
}
// 返回 'a' | 'b'
type KeyofPeople = keyof People;
// type KeyofPeople = 'a' | 'b';
用這種方式可以獲取某個類型的所有鍵。
注意 keyof 只能對類型使用,如果想要對值使用,需要先使用 typeof 獲取類型。
2.4 typeof - 獲取值的類型
typeof
可以獲取值的類型。
// 獲取對象的類型
const obj = { a: '123', b: 123 }
type Obj = typeof obj;
/**
type Obj = {
a: string;
b: number;
}
*/
// 獲取函數的類型
function fn(a: Obj, b: number) {
return true;
}
type Fn = typeof fn;
/**
type Fn = (a: Obj, b: number) => boolean
*/
// ...獲取各種值的類型
注意對於 enum
需要先進行 typeof
操作獲取類型,才能通過 keyof
等類型操作完成正確的類型計算(因爲 enum 可以是類型也可以是值,如果不使用 typeof 會當值計算):
enum E1 {
A,
B,
C
}
type TE1 = keyof E1;
/**
拿到的是錯誤的類型
type TE1 = "toString" | "toFixed" | "toExponential" | "toPrecision" | "valueOf" | "toLocaleString"
*/
type TE2 = keyof typeof E1;
/**
拿到的是正確的類型
type TE2 = "A" | "B" | "C"
*/
2.5 [...] - 元組展開與合併
元組可以視爲長度確定的數組,元組中的每一項可以是任意類型。通過 [... 元組, ... 元組] 語法可以合併兩個元組。
結合元組展開以及 infer 類型推斷,可以實現類型中的數組操作,比如 pop(),後文介紹 infer 時將詳細介紹。
type TupleA = [1, 2, 3]
type TupleB = [...TupleA, 4]
/**
type TupleB = [1, 2, 3, 4]
*/
type TupleC = [0, ...TupleA]
/**
type TupleC = [0, 1, 2, 3]
*/
2.6 [in] - 遍歷對象鍵值
在對象類型中,可以通過 [臨時類型變量 in 聯合類型]
語法來遍歷對象的鍵,示例如下:
// 下述示例遍歷 '1' | '2' | 3' 三個值,然後依次賦值給 K,K 作爲一個臨時的類型變量可以在後面直接使用
/**
下述示例最終的計算結果是:
type MyType = {
1: "1";
2: "2";
3: "3";
}
因爲 K 類型變量的值在每次遍歷中依次是 '1', '2', '3' 所以每次遍歷時對象的鍵和值分別是 { '1': '2' } { '2': '2' } 和 { '3': '3' },
最終結果是這個三個結果取 &
*/
type MyType = {
// 注意能遍歷的類型只有 string、number、symbol,也就是對象鍵允許的類型
[K in '1' | '2' | '3']: K
}
[in] 常常和 keyof 搭配使用,遍歷某一個對象的鍵,做相應的計算後得到新的類型,如下:
type Obj = {
a: string;
b: number;
}
/**
遍歷 Obj 的所有鍵,然後將所有鍵對應的值的類型改成 boolean | K,返回結果如下:
type MyObj = {
a: boolean | "a";
b: boolean | "b";
}
這樣我們就實現了給 Obj 的所有值的類型加上 | boolean 的效果
*/
type MyObj = {
[K in keyof Obj]: boolean | K
}
in 後面還可以接 as,as 後面可以接類型表達式(文檔:https://www.typescriptlang.org/docs/handbook/2/mapped-types.html#key-remapping-via-as)
type Getters<Type> = {
[Property in keyof Type as `get${Capitalize<string & Property>}`]: () => Type[Property]
};
3. 泛型 - 類型函數
TS 的泛型可以類比 Javascript 中的函數
3.1 定義泛型
使用定義泛型:
// 接口泛型
interface Obj1<T> {
a: T
}
// 使用 type 也能定義泛型
type Type1<T> = { b: T }
// 函數泛型
type Fn1 = <T>(...args: any[]) => any;
// 泛型也可以有默認值,這樣如果沒有指定泛型參數,默認是 string
interface Obj1<T = string> {
a: T
}
通過 extends 可以約束泛型:
// extends 後可以接類型表達式
type Fn2 = <T extends string | number>(...args: any[]) => any;
// 泛型可以和函數泛型結合
type Fn3<I> = <T extends string | number>(...args: T[]) => I;
在 <>
中定義的泛型變量可以視爲一個局部函數變量,例如上例中的 T
,可以作爲類型表達式在後續所有涉及類型的地方使用。
3.2 基於泛型創建新類型 - 函數調用
通過 泛型名<類型表達式>
即可使用泛型生成新類型,如下:
type Fn3<I> = <T extends string | number>(...args: T[]) => I;
type MyFn = Fn3<boolean>;
/** 可以看到 Fn3 中的類型已經被替換成了 boolean,也就是我們指定的參數類型
type MyFn = <T extends string | number>(...args: T[]) => boolean
*/
// 使用新類型
const myfn: MyFn = (a: any) => true;
上例中,返回的 MyFn 是一個新類型,可以直接使用新類型進行類型計算,或者進行類型限定。
3.3 泛型遞歸調用 - 函數遞歸
泛型調用支持遞歸:
type RecursiveGenerics<T> = T extends string ? T : RecursiveGenerics<T>;
在上個例子中,我們定義了一個泛型 RecursiveGenerics<T>
,當 T 是 string 的時候,RecursiveGenerics<T>
返回 T,否則返回一個遞歸的結果!
例如遞歸我們就可以做很多有意思的事情了,比如類型對象的深度優先遍歷、實現循環等等。下面我們給斐波那契數列
計算的例子:
// 輔助函數,暫時不用關心
type NumberToArray<T, I extends any[] = []> = T extends T ? I['length'] extends T ? I : NumberToArray<T, [any, ...I]> : never;
type Add<A, B> = [...NumberToArray<A>, ...NumberToArray<B>]['length']
type Sub1<T extends number> = NumberToArray<T> extends [infer _, ...infer R] ? R['length'] : never;
type Sub2<T extends number> = NumberToArray<T> extends [infer _, infer __, ...infer R] ? R['length'] : never;
// 計算斐波那契數列
type Fibonacci<T extends number> =
T extends 1 ? 1 :
T extends 2 ? 1 :
Add<Fibonacci<Sub1<T>>, Fibonacci<Sub2<T>>>;
type Fibonacci9 = Fibonacci<9>;
/** 得到結果
type Fibonacci9 = 34
*/
上述示例中我們成功使用類型完成了斐波那契數列的計算:
重點是下面幾句,根據條件類型判斷遞歸條件,然後調用遞歸。
下述示例使用的條件類型判斷邊界,下一小節會介紹條件類型
// 計算斐波那契數列
type Fibonacci<T extends number> =
// 判斷邊界條件
T extends 1 ? 1 :
T extends 2 ? 1 :
// 遞歸調用
Add<Fibonacci<Sub1<T>>, Fibonacci<Sub2<T>>>;
type Fibonacci9 = Fibonacci<9>;
/** 得到結果
type Fibonacci9 = 34
4. 條件類型 - if else
4.1 條件類型
使用 extends 三元表達式能夠進行條件的判斷,並返回一個新類型,語法如下:
類型表達式1 extends 類型表達式2 ? 類型表達式 : 類型表達式
示例:
type C = 'a' extends 'a' | 'b' ? true : false
/**
type C = true
*/
這裏有幾個注意點:
-
三元表達式的所有位置都可以使用類型表達式
-
返回值是一個類型表達式
4.2 Infer 推斷類型
可以使用 infer 關鍵字推斷條件類型中的某一個條件類型,然後將該類型賦值給一個臨時的類型變量。類型推斷可以用於 extends 後任何可以使用類型表達式的位置,示例:
type Flatten<Type> = Type extends Array<infer Item> ? Item : Type;
上述示例中,當 type 滿足 Array 模式時,將會自動推斷出 T 的類型,並賦值給 Item。例如:
type T = Flatten<string[]>;
/* T = string, 因爲推斷出 string[] = Array<string>,所以 Item = string,類型返回 Item */
注意:infer 只能在條件類型裏面使用。
通過 infer 關鍵字,可以實現很多的內置類型的操作,比如 Parameters、ReturnType 等,實現方式如下:
// 自動推斷參數 P 的類型,如果是則泛型返回值是 P
type MyParameters<T> = T extends (...args: infer P) => any ? P : never;
/**
推斷參數的類型成功
type Params = [a: string, b: number]
*/
type Params = MyParameters<(a: string, b: number) => void>;
// 同樣的方式,我們可以推斷 ReturnType
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
/**
團隊返回值的類型成功
type Ret = void
*/
type Ret = MyReturnType<(a: string, b: number) => void>;
infer 的能力很強大,可以推斷任何類型表達式,例如 infer 還可以和元組或者模版字符串結合,兩個示例如下:
// 計算元組中的第一個元素
type Head<T extends any[]> = T extends [infer F, ...infer R] ? F : never;
// 解析 `1 + 2 + 3` 形式的字符串,並返回 AST
type Parse<T extends string> = T extends `${infer ExpressionA} + ${infer ExpressionB}` ? {
type: 'operator',
left: Parse<ExpressionA>,
right: Parse<ExpressionB>
}: {
type: 'expression',
value: T
};
上述示例中,Head<T>
的計算使用了上文提到的元組展開與合併
知識點,然後結合本小節的infer
,就可以推斷出數組的第一個元素。Parse<T>
中使用了條件類型
、遞歸
知識點,再結合本小節的infer
,就可以實現一個簡單的加法表達式解析器。主要實現是:T extends ${infer ExpressionA} + ${infer ExpressionB}
,如果字符串滿足 A + B
的模式,即可通過 infer
推斷出 A 和 B 的字符串。
4.3 條件聯合類型
如果條件類型的參數是一個聯合類型,則條件類型的計算結果相當於,如下:
// 這裏等價於 (string exetends any ? string[] : never) | (number exetends any ? number[] : never)
type ToArray<Type> = Type extends any ? Type[] : never;
// 計算結果是 string[] | number[]
type StrArrOrNumArr = ToArray<string | number>;
利用這個特性我們可以實現一些有意思的功能,比如 Excludes
:
type Exclude<T, I> = T extends I ? never : T;
type T0 = Exclude<"a" | "b" | "c", "a">;
/**
type T0 = "b" | "c"
*/
原理是聯合類型的每一個類型都會計算一次 extends,然後將最終的結果做聯合,never 在聯合過程中會去除。
二、類型表達式
類型表達式 僅是是本文給出的概念,便於讀者進一步理解類型編程。目前官網文檔中沒有體現類似的概念,如果有不正確的地方,歡迎讀者指正。筆者認爲類型表達式是本文中最核心的一個概念,理解了此概念後,類型計算的問題都將迎刃而解。
值是一個類型的表達式就是類型表達式,通常:
-
定義的類型變量是一個類型表達式
-
類型操作符的操作結果是一個類型表達式,比如
A | B
是一個類型表達式,會返回一個新的類型 -
泛型調用結果是一個類型表達式,比如
A<string>
是一個類型表達式 -
條件類型的結果是一個類型表達式,比如
A extend string ? true : false
是一個類型表達式,返回值是類型 true 或者 false -
...
在需要使用類型的地方,我們就可以使用類型表達式:
-
類型變量定義:比如
type A = B
,B 就可以是一個類型表達式,比如type A = string | Record<string, string>
-
泛型調用:比如
A<B>
,B 就可以是一個類型表示,比如A<string | number | boolean>
-
條件類型:比如
A extend B ? C : D
,A、B、C、D 均可以是類型表達式 -
...
總而言之,所有使用類型的地方,都可以使用類型表達式
,比如類型變量賦值、條件類型、函數參數 / 返回值類型 等等位置。利用 TS 類型表達式的概念,我們就可以進行強大的類型編程能力。
下面通過幾個示例來幫助理解類型表達式的概念。
首先可以拿上面的斐波那契數列計算作爲第一個示例:
type Fibonacci9 = Fibonacci<9>;
// Fibonacci<9> 是一個類型表達式,那麼可以將其作爲 Fibonacci 的輸入,如下:
type Fibonacci99 = Fibonacci<Fibonacci<9>>; // 等價於 type Fibonacci99 = Fibonacci<Fibonacci9>
Fibonacci<9>
是一個類型表達式,我們可以將這個類型表達式作爲泛型的輸入,所以 Fibonacci<Fibonacci<9>>
也是合法的!由此我們可以拓展,所有合法的類型表達式都可以在這裏使用。
另一個示例是條件類型,我們前面介紹了條件類型的語法是:類型表達式1 extends 類型表達式2 ? 類型表達式3 : 類型表達式
。
type MyType = Fibonacci<9> extends Fibonacci<9> ? Fibonacci<10> : Fibonacci<8>;
示例中的四個位置都可以使用類型表達式。
基於類型表達式的概念,我們可以通過堆砌小的類型表達式,完成複雜的類型編程操作!
三、常用知識點總結
1. 函數參數類型自動推導
通過泛型 + 函數參數,可以定義一個類型變量,並且由函數參數自動推導類型變量的值:
function identity<Type>(arg: Type): Type {
return arg;
}
通過傳入一個 string 類型的參數,可以推導出 Type=string ,同時這個類型參數可以在用於組合其他類型!
這個特性非常有用,有時候我們需要推斷出函數參數的類型,並將其保存到一個臨時類型變量中時,這個特性就可以很方便的實現,下面實戰的鏈式調用中用到了這個特性。
2. 動態擴展類型變量
如下,T 中可保存上一次調用 option 後的值,然後通過類型遞歸,擴展 T 的類型,當最後調用 get() 時,拿到的就是擴展後的 T 的類型:
type Chainable<T = {}> = {
option<K extends string, V extends any>(key: K, value: V): Chainable<T & { [key in K]: V }>
get(): T
}
上述示例中,我們使用了默認泛型 + 遞歸兩個特性,利用遞歸保存上下文,我們就可以實現對已有類型變量的擴展。利用這個特性我們可以保存鏈式調用中的上下文。
3. 動態更改對象類型的 key
通過 key in keyof T as xxx
形式可以重寫 key。可以通過這種形式來實現動態更改對象類型的 key,比如實現 OptionalKeys
或者 RequiresKeys
或者 ReadonlyKeys
。
type IsOptional<T, K extends keyof T> = Partial<Pick<T, K>> extends Pick<T, K> ? true : false;
type OptionalKeys<T> = keyof {
[K in keyof T as IsOptional<T, K> extends true ? K : never]: T[K];
};
type RequiredKeys<T> = {
[K in keyof T]: IsOptional<T, K> extends true ? never : T[K]
}
這裏注意 as 後面可以接一個類型表達式,我們可以通過臨時變量 K 以及輔助的類型表達式,實現對鍵的複雜的操作,比如增加、刪除特定的鍵,將特定的鍵標記爲可選,將特定的鍵標記爲 readonly 等等。
上述根據條件將 K 的類型重寫爲 never 可以去掉該 key,但是注意將值的返回類型設置成 never 是無法更改 key 的數量的,如下:
type RequiredKeys<T> = {
[K in keyof T]: IsOptional<T, K> extends true ? never : T[K]
}
返回的 never 值將會變爲 undefined。
四、類型編程實戰
4.1 常用的 ts 內置類型
參考:https://www.typescriptlang.org/docs/handbook/utility-types.html#excludetype-excludedunion
常用的有:
-
Partial
-
Omit
-
Record
-
Pick
-
ReturnType
-
Parameters
我們上面的示例中自己實現了 ReturnType、Parameters,其他的內置類型的實現也類似。基於上述的基礎知識,我們都可以自行實現。
4.2 將某一個對象中的部分參數標記爲可選
使用 Partial 只能將所有參數標記爲可選,如何只標記一部分參數呢?可以如下實現:
type Include<T, I> = T extends I ? T : never;
type MyPartial<T, I> = {
[for K in Exclude<keyof T, I>]: T[K]
} & {
[for K in Include<keyof T, I>]?: T[K]
}
上述示例將 T 的鍵分成兩部分,如果屬於 I 則標記成可選,如果不是則爲必須的。
4.3 給鏈式調用添加類型
題目:https://github.com/type-challenges/type-challenges/blob/master/questions/12-medium-chainable-options/README.md
type Chainable<T = {}> = {
option<K extends string, V extends any>(key: K, value: V): Chainable<T & { [key in K]: V }>
get(): T
}
利用了 TS 函數的範性自動推斷能力以及遞歸函數存儲能力。
4.4 柯里化函數類型
type Head<T extends any[]> = T extends [infer F, ...infer R] ? F : never;
type Rest<T extends any[]> = T extends [infer F, ...infer R] ? R : never;
declare function Currying<T extends any[], P extends boolean>(fn: (...args: T) => P): CurryingRet<T, P>;
type CurryingRet<T extends any[], P> = T['length'] extends 0 ? P : (arg0: Head<T>) => CurryingRet<Rest<T>, P> ;
這裏實現的是簡化版本,更詳細的實現可以參考文章:https://medium.com/free-code-camp/typescript-curry-ramda-types-f747e99744ab。
Head 和 Rest 的計算上文有詳細介紹,這裏我們主要利用了遞歸 + 函數泛型自動推斷的特性。
4.5 簡易加法表達式求值器
實現:Calculator<'1 + 2 + 3'>
輸出 6。
先上效果:
實現思路:
-
實現 Parse,將計算表達式解析成 AST
-
實現 AST 遍歷器
-
實現求值器,輸出最終結果
/* _____________ Your Code Here _____________ */
type ASTExpressionNode = {
type: 'operator' | 'expression';
left?: ASTExpressionNode;
right?: ASTExpressionNode;
value?: keyof NumberMap;
}
type Parse<T> = T extends `${infer ExpressionA} + ${infer ExpressionB}` ? {
type: 'operator',
left: Parse<ExpressionA>,
right: Parse<ExpressionB>
}: {
type: 'expression',
value: T extends keyof NumberMap ? T : never
};
type NumberToArray<T, I extends any[] = []> = I['length'] extends T ? I : NumberToArray<T, [any, ...I]>;
type Add<A, B> = [...NumberToArray<A>, ...NumberToArray<B>]['length'];
type GetValue<T extends ASTExpressionNode> = T['value'] extends string ? T['value'] : never;
type GetLeft<T extends ASTExpressionNode> = T['left'] extends ASTExpressionNode ? T['left'] : never;
type GetRight<T extends ASTExpressionNode> = T['right'] extends ASTExpressionNode ? T['right'] : never;
type NumberMap = {
'0': 0,
'1': 1,
'2': 2,
'3': 3,
'4': 4,
};
type Evaluate<T extends ASTExpressionNode> = T['type'] extends 'expression' ? NumberMap[`${GetValue<T>}`] : Add<Evaluate<GetLeft<T>>, Evaluate<GetRight<T>>>;
type Calculator<T extends string> = Evaluate<Parse<T>>;
type test1 = Parse<'1 + 2'>;
/** 返回
type test1 = {
type: 'operator';
left: {
type: 'expression';
value: "1";
};
right: {
type: 'expression';
value: "2";
};
}
*/
type test2 = Calculator<'1 + 2 + 3'>
/** 返回
type test2 = 6
*/
這裏我們利用了上面提到的幾乎所有知識點:
-
遞歸:表達式的解析和求值都使用了遞歸的能力
-
條件類型:實現遞歸的邊界判斷
-
類型推斷:通過類型推斷實現 Head、Tail 等能力
-
...
感興趣的同學還可以自行實現減法、乘法、除法、取模等操作~
五、常用 TS 類型工具庫
5.1 ts-toolbelt
封裝常用的 TS 類型操作。
地址: https://github.com/millsp/ts-toolbelt。
3.2 typetype
用於自動生成 TS 的類型。
地址: https://github.com/mistlog/typetype。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/-x8iVK-hlQd3-OZDC04A5A