typescript的基本結構_TypeScript系列(三)從程式語言到Conditional Types
技術標籤:typescript的基本結構
前言
在開始討論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。
引腳
- 這裡我們不關注複雜型別,比如function,class等高階型別,因為這類型別一經定義,一般無需修改。