TypeScript系列(三)從編程語言到Conditional Types

前言

在開始討論TypeScript之前,我想先回顧一下大一時的我們是如何學習人生的第一門編程語言的。對於絕大多數同學來說,第一門語言是C,還有我們永遠忘不了的譚浩強與漢諾塔。但,今天寫這篇文章不是為了懷舊,而是為了引導我們的思維,從已知問題出發,來更好地理解TypeScript。

《C程序設計》這本書是這麼講解C語言的。它首先從數據類型、運算符與表達式出發;然後講到了選擇結構程序設計;再後來是循環控制及數組;最後是組織程序的技術,比如函數、結構體等等。

我不得不承認,這本書是個非常好的入門教材。它由淺入深、層層遞進地講解了C語言的各個方面。但當我們學習TypeScript時,我們就沒那麼好的運氣了。你若是通過TypeScript的官方handbook入門的話,我想你能夠體會到前一句話的意思。TS官方的handbook到底存在什麼問題呢?首先,它並沒有由淺入深地講解TypeScript,而是用很多篇幅用來介紹新的ES特性,況且ES特性也覆蓋不全。其次,它沒有將邏輯一致的特性進行良好的分類,這導致我們想查閱某個特性的時候往往不知道它在哪兒。

作為一個補充,TypeScript官方的確提供了一個類似《C程序設計》的文檔,它叫《TypeScript語言規範》。相較於handbook而言,這個組織的更為清晰,可以作為一個深入理解TypeScript的閱讀材料。但這個材料也不是很全面。

綜上所述,這篇文章是作為一個當我們閱讀完handbook和規範後的補充,我們將從編程語言的角度出發,並藉助以往的知識,更好的掌握並運用TypeScript的高級特性。

下面我們將從組成一門編程語言的最基礎的要素(數據、循環遍歷、選擇結構)談起,並結束於回答上篇文章提出的問題。

數據類型

如果我們將TypeScript從JavaScript中剝離,單純地考慮TypeScript。並將TypeScript當作基於某種類型進行推斷的系統,那麼我們就可以觸及TypeScript的本質。

在理解類型檢查的工作機制之前,更為重要的是,上面這句話中的某種類型具體是什麼呢?從官方文檔看,它支持一些基礎類型,比如number、string、boolean等。也支持一些object類型,比如數組、元組、命名類型等。還支持一些高級一點的類型,比如聯合類型、交叉類型等等。這些類型看起來讓人眼花撩亂,讓我們不禁要問,TypeScript到底支持多少種類型?如果我們翻閱它的源碼,會得出一個令人驚訝的數字,27!

這是它源碼中對其類型的定義:

export const enum TypeFlags {
Any = 1 << 0,
Unknown = 1 << 1,
String = 1 << 2,
Number = 1 << 3,
Boolean = 1 << 4,
Enum = 1 << 5,
BigInt = 1 << 6,
StringLiteral = 1 << 7,
NumberLiteral = 1 << 8,
BooleanLiteral = 1 << 9,
EnumLiteral = 1 << 10, // Always combined with StringLiteral, NumberLiteral, or Union
BigIntLiteral = 1 << 11,
ESSymbol = 1 << 12, // Type of symbol primitive introduced in ES6
UniqueESSymbol = 1 << 13, // unique symbol
Void = 1 << 14,
Undefined = 1 << 15,
Null = 1 << 16,
Never = 1 << 17, // Never type
TypeParameter = 1 << 18, // Type parameter 泛型
Object = 1 << 19, // Object type
Union = 1 << 20, // Union (T | U)
Intersection = 1 << 21, // Intersection (T & U)
Index = 1 << 22, // keyof T
IndexedAccess = 1 << 23, // T[K]
Conditional = 1 << 24, // T extends U ? X : Y
Substitution = 1 << 25, // Type parameter substitution 泛型實例
NonPrimitive = 1 << 26, // intrinsic object type
}

從這些類型定義我們可以看出,TypeScript是個非常純粹的類型推斷系統。對於一些高級特性,比如泛型、聯合類型,在這裡也只是這個類型推斷系統中的一個基礎類型。甚至包括我們今天重點要講解的Conditional Type。

這些基礎數據類型便是我們在寫類型聲明時所依賴的基石,我們定義的其它類型都將直接或間接地引用這些類型,並在類型檢查中進行匹配。關於如何使用類型並定義派生的類型,官方文檔基本上都提到了,這裡不再贅述。那麼,如何遍歷並修改我們定義的類型呢?這將是我們下一節關注的重點。

循環遍歷

說起循環遍歷,我們最常接觸的無非是兩種數據類型,數組和字典。更為複雜一點的呢,則是由數組和字典組成的複雜結構1。在TS的類型中,無論我們遍歷的是數組或字典,都是一樣的。因為對於數組而言,遍歷的時候就是以數字作為鍵的字典。

在TS中,我們可以用in關鍵字來進行遍歷,用keyof關鍵字來獲取所有的鍵。首先,我們來看一個很簡單的例子。在這個例子中,我想定義一個Copy,它可以對於給定的任意數組或元組,複製一個新的類型。雖然在真實場景下,這個例子沒啥意義,這裡只是演示的目的:

type Copy<T extends any[]> = {
[KEY in keyof T]: T[KEY]
};

在引入泛型之前,我們可以這樣理解這段代碼。這裡,我定義了個Copy的類型,它接收一個數組類型的T,返回一個鍵從T中取的KEY,值是T[KEY]的類型。當TS展開這段代碼時,這裡的KEY就會循環獲取T中定義的KEY,並根據表達式生成最終的類型。我們看一下如何使用它:

type MyTuple = [number, string];
type CopiedTuple = Copy<MyTuple>;
// CopiedTuple和MyTuple一模一樣

那麼,讀者可能要問了,那這個循環的意義是什麼呢?循環本身是沒有意義的,循環的意義來自於我們在循環體中做了什麼。在這類對類型的循環中,我們可以進行兩種類型的修改,一種是對屬性的修改,另一種是對值類型的修改。

首先,我們看一下對屬性的修改。在TS中,有兩個屬性的修飾符readonly?。readonly表示只讀,?表示可選。與之對應的,TS提供了兩個操作符+-。我們看個例子:

type Immutable<T extends any[]> = { // 接收一個數組類型,返回一個只讀數組類型
+readonly [P in keyof T]: T[P];
};
type Mutable<T extends any[]> = { // 接收一個數組類型,返回一個可修改數組類型
-readonly [P in keyof T]: T[P];
};
type Optional<T extends any[]> = { // 接收一個數組類型,返回一個元素類型是optional的數組類型
[P in keyof T]+?: T[P];
};
type Required<T extends any[]> = { // 接收一個數組類型,返回一個元素類型是required的數組類型
[P in keyof T]-?: T[P];
};

在TS中,對於上面的代碼,我們可以默認省略掉+號。比如:

type Immutable<T extends any[]> = { // 接收一個數組類型,返回一個只讀數組類型
readonly [P in keyof T]: T[P];
};

那麼,對於字典類型呢?其實是一樣的,我們只需要將上面代碼中的類型(extends any[])約束刪掉就可以了。比如:

type Immutable<T> = { // 接收一個字典或數組類型,返回一個屬性是只讀的新類型。
readonly [P in keyof T]: T[P];
};

那麼,對於層級比較深的類型呢?寫法也是類似的。比如:

type DeepObj = {
l1: {
l2: {
l3: string; // 將該層級的屬性都變為只讀的
}
}
}
type ImmutableL3<T> = {
[L1KEY in keyof T]: {
[L2KEY in keyof T[L1KEY]]: Immutable<T[L1KEY][L2KEY]> // 遍歷到第二層
} // 將第二層的值類
type L3ReadOnlyObj = ImmutableL3<DeepObj>; // 可以這樣使用。

其次,我們來看一下如何對值類型進行修改。比如:

type StrAndNumberNumbers = [string, number, string]; // 一個由字元串和數字組成的Tuple
type NumbersOf<T extends any[]> = {
[P in keyof T]: number; // 將值類型定義為數字
}
type AllNumbers = NumbersOf<StrAndNumberNumbers>; // 將值類型轉化為number而長度不變
// type AllNumbers = [number, number, number]

我們再稍微深入一下,如果我們想將上面例子中的StrAndNumberNumbers中第一個string轉化為number類型,而忽略其它的呢?我們如何才能知道當前的P是不是第一個呢?暫時看好像搞不定,但我相信你在閱讀完下一小節後,會自信的說出,I Can!

選擇結構

選擇結構是編程語言中經久不衰的話題,不論是我們剛入行時接觸的if else、三元表達式,還是我們後來想要搞明白的多態、泛型。本質上講,它們都是一種選擇結構。它們的區別只是由我們程序員顯示地控制選擇邏輯還是交由編譯器或運行時控制。

Conditional Type是TS 2.8引入的一個特性,我認為它在TS史上是具有劃時代意義的。為什麼這麼講,因為它是選擇結構!有了選擇結構,我們就可以對類型進行增刪改查、拆分和組合,這對於維護一個大型的、有著大量類型定義的項目而言,意義重大。

關於Conditional Type的基本用法,這裡的官方文檔比較詳細和全面,我就不一一贅述。這裡,我只想列舉一下一些常見的組合:

首先,可以配合never去除某些不必要的類型或屬性,比如:

// 去除類型
type Exclude<T, U> = T extends U ? never : T;
//??可以這麼理解這句,T是不是一種U?若是,不可能,若不是,返回T
type StringOrNumber = Exclude<undefined | null | string | number, void>;
//??可以這麼展開一下,(undefined extends void ? never : undefined |
// null extends void ? never : null |
// string extends void ? never : string |
// number extends void ? never : number)
// => never | never | string | number
// => string | number
?
?
// ??定義一個可以去除一些屬性的類型
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>
type ITBoss = {
name: string;
title: "BOSS";
department: "IT";
age: number;
sex: "MALE";
}; // 定義一個IT部門的老大
type Husband = Omit<ITBoss, title | department> & { wife: string }; // 復用ITBoss轉換為Husband
//??type Husband = { name: string; age: number; sex: "MALE"; wife: string }

其次,它也可以配合infer來獲取某些想要的類型,比如:

type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
//??用來獲取函數的返回類型
type Fun = () => Husband;
type AnotherHusband = ReturnType<Fun>;
//??type AnotherHusband = { name: string; age: number; sex: "MALE"; wife: string }

Conditional Type是不是很靈活呢?我們可以用它玩出很多花樣。但關於上面的例子,我要提醒大家一下的是,實踐中最好不要那樣使用Omit,這樣組合雖然能夠讓代碼簡化,但對後來閱讀代碼的人有點痛苦。合理的抽象是必須的,但過度抽象只能讓代碼更加難以維護。

上面提到的有些泛型類型已經內置在TypeScript中,大家無須重複定義,比如這些:

  • Partial<T>:使所有的屬性變為optional
  • Required<T>:使所有屬性變為必選項
  • Readonly<T>:使所有屬性只讀
  • Pick<T, K extends keyof T>:從T中選擇一些鍵組成一個新的類型
  • Record<K extends keyof any, T>:構造一個K為鍵,T為值類型的新類型
  • Exclude<T, U>:從T中去除和U匹配的部分
  • Extract<T, U>:從T中抽出和U匹配的部分
  • NonNullable<T>:從T中取出非空的部分
  • Parameters<T extends (...args: any) => any>:取出函數的參數列表的類型簽名
  • ConstructorParameters<T extends new (...args: any) => any>: 取出構造函數的參數列表的類型簽名
  • ReturnType<T extends (...args: any) => any>:取出函數的返回類型
  • InstanceType<T extends new (...args: any) => any>:取出類的實例類型

最後,我在這裡回答一下上一篇--TypeScript系列(二)從immutable到const contexts留下的問題。如果沒有閱讀上一篇或已經忘記的同學,這裡的最好看一下。

再談flatOptionsToDict

上一篇,我們提出了一個問題。這個問題是這樣的,我們希望定義一個flatOptionsToDict函數,它接受任意的一個類似options({key, value}[])這樣的數據,然後返回一個類型明確的optionMap({[key]: value})。通過學習本文的這些知識,我相信讀者已經可以自己解決那個問題了。這裡,我將最終的方案放在下方,方便大家查閱對比。

const enum Fruits {
APPLE = "APPLE",
BANANA = "BANANA"
};
const options = [
{
key: Fruits.APPLE,
value: "蘋果"
},
{
key: Fruits.BANANA,
value: "香蕉"
}
] as const; // TypeScript系列(二)從immutable到const contexts 中提到的 const contexts
?
type Key<T> = T extends { key: infer V } ? V : never;
//??用來獲取key鍵所對應的值類型。
?
function flatOptions<T extends { key: any, value: any }>(xs: readonly T[]): {
readonly [KEY in Key<T>]: T extends { key: KEY; value: infer U; } ? U : never;
//??該函數的返回值是個object,它的鍵是key所對應的值。
// 它的值我們可以通過這個選擇結構拿到,我們是這樣選擇的,若T的結構是我們想要的結構,則拿出其中value所對應的值。
} {
return xs.reduce((acc, item) => {
acc[item.key] = item.value;
return acc;
}, {} as any);
};
?
const optionMap = flatOptions(options);
// const optionMap: {
// readonly APPLE: "蘋果";
// readonly BANANA: "香蕉";
// }
const value = optionMap[Fruits.APPLE];
// const value: "蘋果"

走到這裡,我們會發現,TS的類型推導是個非常強大的工具。對於上面這些代碼,我們甚至可以藉助類型推導,在編譯之前運行它,並將執行結果直接內聯到需要使用value的地方。如果TS某一天實現了這個特性,那麼它將類似於C語言中的宏,我們可以藉助於它和webpack,在構建時生成較為優化的代碼,並且,能夠實現對生產代碼毫無影響的feature toggle。

引腳

  1. 這裡我們不關注複雜類型,比如function,class等高級類型,因為這類類型一經定義,一般無需修改。

推薦閱讀:

TAG:TypeScript | JavaScript | webpack |