如何進階TypeScript功底?一文帶你理解TS中各種高階語法

語言: CN / TW / HK

theme: awesome-green

引言

TypeScript 的重要性我不在強調了,我相信仍然會有大多數前端開發者碰到複雜型別一概使用 any 處理。

我寫這篇文章的目的就是為了讓你告別 AnyScript ,文章告別晦澀的概念結合例項來為你講述一系列 TS 高階用法:分發、迴圈、協變、逆變、unknown ... 等等之類。

讓我們告別枯燥的概念,結合真實用例來掌握 TypeScript 從此徹底告別 AnyScript !

文章並不會從基礎的 TS 語法開始講解,如果你還不瞭解什麼是 TypeScript 強烈建議閱讀 TS 官方文件

泛型

泛型基礎

熟悉 Java 或者 C# 的朋友對於 泛型的概念也許非常瞭解,關於泛型的概念這裡我並不打算在文章中進行贅述了。

關於如何解釋泛型,我看到的最好的一句話概括把明確型別的工作推遲到建立物件或呼叫方法的時候才去明確的特殊的型別,簡單點來講我們可以將泛型理解成為把型別當作引數一樣去傳遞。

比如這樣一個簡單的例子:

```ts function identity(arg: T): T { return arg; }

// 呼叫identity時傳入name,函式會自動推匯出泛型T為string,自然arg型別為T,返回值型別也為T const userName = identity('name'); // 同理,當然你也可以顯示宣告泛型 const id = identity(1); ```

它在 TS 中的確非常重要,同時也有許多非常優秀的文章來講述它的基礎用法。它既重要又基礎,是掌握 TS 高階用法的重中之重。

如果你目前還不是非常瞭解泛型,那麼強烈建議你去閱讀 Generics Type

介面泛型位置

之所以將介面中的泛型單獨拉出來和大家講述,是因為在日常工作中經常會碰到一些同事對於泛型介面位置的不理解。

空口無憑,我們來看看這樣一個簡單的例子:

```ts // 定義一個泛型介面 IPerson表示一個類,它返回的例項物件取決於使用介面時傳入的泛型T interface IPerson { // 因為我們還沒有講到unknown 所以暫時這裡使用any 代替 new(...args: unknown[]): T; }

function getInstance(Clazz: IPerson) { return new Clazz(); }

// use it class Person {}

// TS推斷出函式返回值是person例項型別 const person = getInstance(Person); ```

上邊的 Demo 是一個非常再不同不過的例子了,我們定義介面 IPerson 時,這個介面定義了一個泛型引數 T 表示返回的例項型別。

當使用時,我們需要在使用介面時宣告該 T 型別,比如IPerson<T>

接下來我們在看對比另外一個例子:

```ts // 宣告一個介面IPerson代表函式 interface IPerson { // 此時注意泛型是在函式中引數 而非在IPerson介面中 (a: T): T; }

// 函式接受泛型 const getPersonValue: IPerson = (a: T): T => { return a; };

// 相當於getPersonValue(2) getPersonValue(2) ```

這裡上下兩個例子特別像強調的是關於泛型介面中泛型的位置是代表完全不同的含義:

  • 當泛型出現在介面中時,比如interface IPerson<T> 代表的是使用介面時需要傳入泛型的型別,比如IPerson<T>

  • 當泛型出現在介面內部時,比如第二個例子中的 IPerson介面代表一個函式,介面本身並不具備任何泛型定義。而介面代表的函式則會接受一個泛型定義。換句話說介面本身不需要泛型,而在實現使用介面代表的函式型別時需要宣告該函式接受一個泛型引數。

趁熱打鐵,我們來看這樣一個例子:當我們希望實現一個數組的 forEach 方法時,嘗試使用泛型來實現:

```ts // 定義callback遍歷方法 兩種方式 應該採用哪一種? type Callback = (item: T) => void // 第二種宣告方式 type Callback = (item: T) => void;

const forEach = (arr: T[], callback: Callback) => { for (let i = 0; i < arr.length - 1; i++) { callback(arr[i]) } };

forEach(['1', 2, 3, '4'], (item) => {}); ```

關於 forEach 方法我相信大夥兒都已經非常瞭解了,這裡我們嘗試使用 TS 來實現這個方法。此時我們將 callback 型別單獨抽離出來。

上邊的寫法有兩種宣告方式,小夥伴們覺得應該關於 forEach 中的 callback 型別定義應該採用第幾種呢?第一種 or 第二種?

大家可以結合上邊的兩個例子自己先稍微思考下。


答案是第二種方式type Callback<T> = (item: T) => void;

這裡有一個非常關鍵的點需要注意,所謂 TS 是一種靜態型別檢測,並不會執行你的程式碼。

我們先來分析第二種方式的型別定義,我稍微將呼叫時的程式碼補充完整(這樣方便大夥兒理解):

```ts // item的型別取決於呼叫函式時傳入的型別引數 type Callback = (item: T) => void;

const forEach = (arr: T[], callback: Callback) => { for (let i = 0; i < arr.length - 1; i++) { // 這裡呼叫callback時,ts並不會執行我們的程式碼。 // 換句話說:它並不清楚arr[i]是什麼型別 callback(arr[i]); } };

// 所以這裡我們並不清楚 callback 定義中的T是什麼型別,自然它的型別還是T forEach(['1', 2, 3, '4'], (item: T) => {}); ```

如果採用第二種宣告方式,在 forEach 內部的 callback 函式呼叫時,才會清楚函式傳入的引數型別。顯然forEach 呼叫時無法正確推斷出 item 的型別定義。

接下來,我們來看看第二種方式:

```ts // item 的型別取決於使用型別時傳入的泛型引數 type Callback = (item: T) => void;

// 在宣告階段就已經確定了 callback 介面中的泛型引數為外部傳入的 const forEach = (arr: T[], callback: Callback) => { for (let i = 0; i < arr.length - 1; i++) { callback(arr[i]); } };

// 自然,我們在呼叫forEach時顯式宣告泛型引數為 string | number 型別 // 所以根據forEach的函式型別定義時, // 自然 callback 的 item 也會在定義時被推導為 T 也就是所謂的 string | number 型別 forEach(['1', 2, 3, '4'], (item) => {}); ```

所以,這一點在日常開發中希望小夥伴們一定要特別留意:在泛型介面中泛型的宣告位置不同所產生的效果是完全不同的。

泛型約束

所謂泛型約束,通俗點來講就是約束泛型需要滿足的格式。提到它,有一個非常經典的案例:

ts // 定義方法獲取傳入引數的length屬性 function getLength<T>(arg: T) { // throw error: arr上不存在length屬性 return arg.length; }

這裡,我們定義了一個 getLength 方法,希望函式獲取傳入引數的 length 屬性。

因為傳入的引數是不固定的,有可能是 string 、 array 、 arguments 物件甚至一些我們自己定義的 { name:"19Qingfeng", length: 100 },所以我們為函式增加泛型來為函式增加更加靈活的型別定義。

可是隨之而來的問題來了,那麼此時我們在函式內部訪問了 arg.length 屬性。但是此時,arg 所代表的泛型可以是任意型別。

比如我們可以傳入一個 boolean ,那麼此時函式中的泛型 T 代表 boolean 型別,訪問 boolean.length ? 這顯然是一個 bug 。

那麼如果解決這個問題呢,當然就提到了所謂的泛型約束 extends 關鍵字。

我們先來看看如何使用它:

```ts interface IHasLength { length: number; }

// 利用 extends 關鍵字在宣告泛型時約束泛型需要滿足的條件 function getLength(arg: T) { // throw error: arr上不存在length屬性 return arg.length; }

getLength([1, 2, 3]); // correct getLength('123'); // correct getLength({ name: '19Qingfeng', length: 100 }); // correct // error 當傳入true時,TS會進行自動型別推導 相當於 getLength(true) // 顯然 boolean 型別上並不存在擁有 length 屬性的約束,所以TS會提示語法錯誤 getLength(true); ```

型別關鍵字

其實原本一些簡單的型別關鍵字我並不打算在文章中去闡述的,但是後續許多高階型別以及高階概念正是基於這些來實現的,所以文章為了照顧一些不是特別熟悉 TS 的小夥伴還是對於這部分特意進行了講述。

keyof 關鍵字

所謂 keyof 關鍵字代表它接受一個物件型別作為引數,並返回該物件所有 key 值組成的聯合型別。

比如:

```ts interface IProps { name: string; age: number; sex: string; }

// Keys 型別為 'name' | 'age' | 'sex' 組成的聯合型別 type Keys = keyof IProps ```

看上去非常簡單對吧,需要額外注意的一點是keyof any 時候我們會得到什麼型別呢?

小夥伴們可以稍微思考下 keyof any 會得到什麼樣的型別。

ts // Keys 型別為 string | number | symbol 組成的聯合型別 type Keys = keyof any

其實這是非常容易理解,any 可以代表任何型別。那麼任何型別的 key 都可能為 string 、 number 或者 symbol 。所以自然 keyof any 為 string | number | symbol 的聯合型別。

在之後的高階型別中會利用到keyof any的特性,所以這裡提前拿出來讓大家預熱下。

在瞭解了 keyof 關鍵字之後,讓我們結合泛型來實現一個簡單的例子來練練手。

比如此時,我們希望實現一個函式。該函式希望接受兩個引數,第一個引數為一個物件object,第二個引數為該物件的 key 。函式內部通過傳入的 object 以及對應的 key 返回 object[key]

ts function getValueFromKey(obj: object, key: string) { // throw error // key的值為string代表它僅僅只被規定為字串 // TS無法確定obj中是否存在對應的key return obj[key]; }

顯然,我們直接為引數宣告型別這是會報錯的。同學們可以結合剛剛學過的 keyof 關鍵字配合泛型來思考一下如何消除 TS 的錯誤提示。


ts // 函式接受兩個泛型引數 // T 代表object的型別,同時T需要滿足約束是一個物件 // K 代表第二個引數K的型別,同時K需要滿足約束keyof T (keyof T 代表object中所有key組成的聯合型別) // 自然,我們在函式內部訪問obj[key]就不會提示錯誤了 function getValueFromKey<T extends object, K extends keyof T>(obj: T, key: K) { return obj[key]; }

它的實現非常簡單,這裡沒有寫出來的同學可以好好看看文章中上述的內容。

is 關鍵字

原本是不打算講述這個基礎概念的,奈何之前在一次面試中因為 is 關鍵字翻了車哈哈。

面試官問我熟悉 Ts 嗎,答案一定是肯定的。結果問了我一個 is 關鍵字代表的含義,當時的我簡直是百思不得其解.. “難道你問的不是 as 嗎”,is 究竟是個什麼東西好像從來沒有聽說過。

於是面試結束後趕快搜了搜,結果竟然就是業務中經常用到的型別謂詞。。。

所謂 is 關鍵字其實更多用在函式的返回值上,用來表示對於函式返回值的型別保護。

它的用法非常簡單:

```ts // 函式的返回值型別中 通過型別謂詞 is 來保護返回值的型別 function isNumber(arg: any): arg is number { return typeof arg === 'number' }

function getTypeByVal(val:any) { if (isNumber(val)) { // 此時由於isNumber函式返回值根據型別謂詞的保護 // 所以可以斷定如果 isNumber 返回true 那麼傳入的引數 val 一定是 number 型別 val.toFixed() } } ```

所以,通常我們使用 is 關鍵字(型別謂詞)在函式的返回值中,從而對於函式傳入的引數進行型別保護。

infer 關鍵字

infer 關鍵字我不打算放在這裡和大家描述了,我會在下面的內容和大家逐步切入對應的關鍵字。

TS 高階概念

分發

在講述分發的概念,我會先和你聊聊 TS 中的 Conditional Types (條件型別)。

因為大多數高階型別都是基於條件型別,同時分發的概念也和 Conditional Types 息息相關,所以我們先來看看所謂的 Conditional Types 究竟是什麼。

```ts type isString = T extends string ? true : false;

// a 的型別為 true let a: isString<'a'>

// b 的型別為 false let b: isString<1>; ```

上邊我們通過 type 關鍵字定義了一個所謂的 isString 型別,它接受一個泛型引數 T 。

isString 型別內部通過 extends 關鍵字結合 ? 和 : 實現了所謂的 Conditional Types (條件型別)判斷。

type isString<T> = T extends string ? true : false;

稍微翻譯翻譯上邊這段程式碼,當泛型 T 滿足 string 型別的約束時,它會返回 true ,否則則會返回 false 型別。

其實所謂的條件型別就是這麼簡單,看起來和三元表示式非常相似,甚至你完全可以將它理解成為三元表示式。只不過它接受的是型別以及判斷的是型別而已。

需要額外注意的是:

  • 這裡的 T extends string 更像是一種判斷泛型 T 是否滿足 string 的判斷,和之前所講的泛型約束完全不是同一個意思。

上述我們講的泛型約束是在定義泛型時進行對於傳入泛型的約束,而這裡的 T extends string ? true : false 並不是在傳入泛型時進行的約束。

在使用 isString 時,你可以為它傳入任意型別作為泛型引數的實現。但是 isString 型別內部會對於傳入的泛型型別進行判斷,如果 T 滿足 string 的約束條件,那麼返回型別 true,反過來則是 false 。

  • 其次,需要注意的是條件型別 a extends b ? c : d 僅僅支援在 type 關鍵字中使用。

在瞭解了泛型約束之後,我們在回到所謂分發的概念上來。

一起來看看這樣一個例子:

```ts type GetSomeType = T extends string ? 'a' : 'b';

let someTypeOne: GetSomeType // someTypeone 型別為 'a'

let someTypeTwo: GetSomeType // someTypeone 型別為 'b'

let someTypeThree: GetSomeType; // what ? ```

這裡我們定義了一個 GetSomeType 的型別,它接受一個泛型引數 T 。這個泛型引數 T 在傳入時需要滿足為 string 和 number 的聯合型別的約束。(換句話說,要麼為 string 要麼為 number 要麼為 string | number)。

  • 首先,someTypeOne 變數的型別為 GetSomeType<string>

因為我們為 someTypeOne 定義時傳入了 string 的型別引數,所以按照條件型別來判斷,string extends string 明顯是滿足的,所以返回型別 'a'。

  • 同理,someTypeTwo 變數的型別也會被推斷為 'b',這個過程我就不在累贅了。

那麼重點來了,someTypeThree 定義時的型別 GetSomeType<'string' | 1> 我們傳入的泛型引數為聯合型別時 'string' | 1 時,它會的到什麼型別呢?

首先不難想象,我們按照正常邏輯來思考。'string' | 1 一定是不滿足 T extends string,因為一個 'string' | 1 的聯合型別一定是無法和 string 型別做相容的。

那麼按照我們之前的邏輯來梳理,理所應當 someTypeThree 的型別應該是 'b' 對吧。

可是結果真如那麼簡單的話,那麼我還舉出來這個例子做什麼呢?

image.png

很驚訝吧,someTypeThree 的型別竟然被推導成為了 'a' | 'b' 組成的聯合型別,那麼為什麼會這樣呢。

其實這就是所謂分發在搗鬼。

我們拋開晦澀的概念來解讀分發,結合上邊的 Demo 來說所謂的分發簡單來說就是分別使用 string 和 number 這兩個型別進入 GetSomeType 中進行判斷,最終返回兩次型別結果組成的聯合型別。

當然,你可以在使用 GetSomeType 你可以傳入n個型別組成的聯合型別作為泛型引數,同理它會進行進入 GetSomeType 型別中進行 n 次分發判斷。

ts // 你可以這樣理解分發 // 虛擬碼:GetSomeType<string | number> = GetSomeType<string> | GetSomeType<number> let someTypeThree: GetSomeType<string | number>

自然我們就得到的 someTypeThree 型別為 "a" | "b" 。

相信看到這裡的同學都已經能理解分發代表的是什麼含義。

那麼,什麼情況下會產生分發呢?滿足分發需要一定的條件,我們來一起看看:

  • 首先,毫無疑問分發一定是需要產生在 extends 產生的型別條件判斷中,並且是前置型別。

比如T extends string | number ? 'a' : 'b'; 那麼此時,產生分發效果的也只有 extends 關鍵字前的 T 型別,string | number 僅僅代表一種條件判斷。

  • 其次,分發一定是要滿足聯合型別,只有聯合型別才會產生分發(其他型別無法產生分發的效果,比如 & 交集中等等)。

  • 最後,分發一定要滿足所謂的裸型別中才會產生效果。

這裡的裸型別稍微和大家解釋下,比如這樣:

```ts // 此時的T並不是一個單獨的”裸型別“T 而是 [T] type GetSomeType = [T] extends string[] ? 'a' : 'b';

// 即使我們修改了對應的型別判斷,仍然不會產生所謂的分發效果。因為[T]並不是一個裸型別 // 只會產生一次判斷 [string] | number extends string[] ? 'a' : 'b' // someTypeThree 仍然只有 'b' 型別 ,如果進行了分發的話那麼應該是 'a' | 'b' let someTypeThree: GetSomeType<[string] | number>; ```

同樣,在瞭解了分發的概念和清楚瞭如何會產生分發的效果後。趁熱打鐵我們來看看利用分發我們可以實現什麼樣的效果:

在 TypeScript 內部擁有一個高階內建型別 Exclude 意為排除,它的用法如下:

```ts type TypeA = string | number | boolean | symbol;

// ExcludeSymbolType 型別為 string | number | boolean,排除了symbol型別 type ExcludeSymbolType = Exclude; ```

用法非常簡單,Exclude 內建型別會接受兩個型別泛型引數。它會構造一個新的型別,這個型別會排除所有 TypeA 型別中滿足 symbol 的型別。

那麼,如果讓你來實現一個 Exclude 內建型別,你會如何實現呢?同學們可以結合分發自行思考下。


如果沒有想出來的小夥伴,強烈建議在重新好好溫習一下分發這個章節。

```ts type TypeA = string | number | boolean | symbol;

type MyExclude = T extends K ? never : T;

// ExcludeSymbolType 型別為 string | number | boolean,排除了symbol型別 type ExcludeSymbolType = MyExclude; ```

其實它的實現非常簡單,上述我們通過分發來實現了對應的 MyExclude 型別。

MyExclude 型別接受兩個泛型引數,因為 T extends K ? never : T 中 T 滿足裸型別並且在 extends 關鍵字前。

同時,我們傳入的 TypeA 為聯合型別,那麼滿足分發的所有條件。則會產生分發效果,也就是說會將聯合型別 TypeA 中所有的單個型別依次進入 T extends K ? never : T; 去判斷。

當滿足條件時,也就是 T extends symbol | boolean 時,此時會得到 never 。(這裡的 never 代表的也就是一個無法達到的型別,不會產生任何效果),自然就會被忽略。

而如果不滿足 T extends symbol | boolean 則會被記錄,最終返回不滿足 T extends symbol | boolean 的所有型別組成的聯合型別,也就是所謂的 string | number

當然和 Exclude 相反效果的內建型別 ExtractNonNullable也是基於分發實現的,有興趣的小夥伴可以自行查閱實現。

迴圈

TypeScript 中同樣存在對於型別的迴圈語法(Mapping Type),通過我們可以通過 in 關鍵字配合聯合型別來對於型別進行迭代。

比如這樣:

```ts interface IProps { name: string; age: number; highSchool: string; university: string; }

// IPropsKey型別為 // type IPropsKey = { // name: boolean; // age: boolean; // highSchool: boolean; // university: boolean; // } type IPropsKey = { [K in keyof IProps]: boolean }; ```

其實相對來說迴圈關鍵字 in 比較簡單,上述程式碼我們聲明瞭一個所謂的 IPropsKey 的型別:

首先可以看到這個型別是一個物件,物件中的 key 為 [] 包裹的可計算值,value 為 boolean。

keyof IProps 我們在之前提到過它會返回 IProps 所有 key 組成的聯合型別,也就是 'name' | 'age' | 'highSchool' | 'university'

[K in keyof IProps] 正是我們在型別內部聲明瞭一個變數 K 。

你可以理解為 in 關鍵字的作用類似於 for 迴圈,它會迴圈 keyof IProps 這個聯合型別中的每一項型別,同時在每一次迴圈中將對應的型別賦值給 K 。

最終,通過一次一次迴圈我們到了最終的新型別 interface IProps { name: string; age: number; highSchool: string; university: string; }

那麼在 TS 中我們可以利用迴圈的特性來做什麼呢?不知道大家有沒有用到 Partial 之類的內建型別。

所謂的 Partial 功能非常簡單:它會構造一個新的型別,這個型別會將之前型別中的所有屬性都變為可選。

比如這樣:

image.png

可以看到我們通過 Partial 傳入 IInfo 型別,它返回一個新型別 OptinalInfo,OptinalInfo 會將 IInfo 中所有的屬性都變為可選型別。

同樣,大家可以自己動手來實現一下它。其實結合我們剛剛說到的迴圈來實現會非常簡單。


```ts interface IInfo { name: string; age: number; }

type MyPartial = { [K in keyof T]?: T[K] };

type OptionalInfo = MyPartial; ```

看起來非常簡單對吧,我們通過 in 迴圈傳入的 IInfo,同時構造了一個新的型別它的 key 和 IInfo 是一模一樣的。

只不過僅僅是所有屬性 key 是可選的,而非必填。

當然需要注意的是我們剛才提到的所有關鍵字,比如 extends 進行條件判斷或者 in 進行型別迴圈時,僅僅支援在 type 型別宣告中使用,並不可以在 interface 中使用,這也是 type 和 interface 宣告的一個不同。

當然,還有許多內建型別同樣利用了迴圈,比如 Required、Readonly 等等。

同學們可以思考下如果讓你實現一個 Required 應該如何實現,它會利用到一些Mapping Modifiers(對映修飾符),有興趣的朋友可以實現一下練練手。

當然在迴圈的最後,我們來思考另一個問題。其實你會發現無論是 TS 內建的 Partial 還是我們剛剛自己實現的 Partial ,它僅僅物件中一層的轉化並不能遞迴處理。

比如說:

```ts

interface IInfo { name: string; age: number; school: { middleSchool: string; highSchool: string; university: string; } }

type OptionalInfo = Partial; ```

image.png

可以看到利用 Partial 關鍵字僅僅對於物件型別中的最外層進行了可選標記。

但是對於內層巢狀型別比如 school 仍是一個物件型別,那麼此時是無法深度進入 school 型別中進行標記的。

那麼假如此時我有需求希望實現深度可選,應該如何做呢?大家可以往上邊提到過的條件判斷和迴圈結合來考慮下。


```ts

interface IInfo { name: string; age: number; school: { middleSchool: string; highSchool: string; university: string; }; }

// 其實實現很簡單,首先我們在構造新的型別value時 // 利用 extends 條件判斷新的型別value是否為 object // 如果是 -> 那麼我仍然利用 deepPartial 進行包裹遞迴可選處理 // 如果不是 -> 普通型別直接返回即可 type deepPartial = { [K in keyof T]?: T[K] extends object ? deepPartial : T[K]; };

type OptionalInfo = deepPartial;

let value: OptionalInfo = { name: '1', school: { middleSchool:'xian' }, }; ```

不賣關子了,如果對於文章之前的知識點你都掌握了,那麼我相信實現這個功能對你來說簡直是小菜一碟。

其實看到這裡,TS 內建的一些型別比如 Pick 、 Omit 大家都可以嘗試自己去實現下了。我們之前說到了知識點已經可以完全涵蓋這些內建型別的實現。

逆變

許多不是很熟悉 TS 的朋友對於逆變和協變的概念會感到莫名的恐懼,沒關係。它們僅僅代表闡述表現的概念而已,放心我們並不會從概念入手而是通過例項來逐步為你揭開它的面紗。

首先,我們先來思考這樣一個場景:

```ts let a!: { a: string; b: number }; let b!: { a: string };

b = a ```

我們都清楚 TS 屬於靜態型別檢測,所謂型別的賦值是要保證安全性的。

通俗來說也就是多的可以賦值給少的,上述程式碼因為 a 的型別定義中完全包括 b 的型別定義,所以 a 型別完全是可以賦值給 b 型別,這被稱為型別相容性。

之後,我們再來思考這樣一段程式碼:

```ts let fn1!: (a: string, b: number) => void; let fn2!: (a: string, b: number, c: boolean) => void;

fn1 = fn2; // TS Error: 不能將fn2的型別賦值給fn1 ```

我們將 fn2 賦值給 fn1 ,剛剛才提到型別相容性的原因 TS 允許不同型別進行互相賦值(只需要父/子集關係),那麼明明 fn2 的引數包括了所有的 fn1 為什麼會報錯?

上述的問題,其實和剛剛沒有什麼本質區別。我們來換一個角度來理解這個問題:

針對於 fn1 宣告時,函式型別需要接受兩個引數,換句話說呼叫 fn1 時我需要支援兩個引數的傳入分別是 a:stringb:number

同理 fn2 函式定義時,定義了三個引數那麼呼叫 fn2 時自然也需要傳入三個引數。

那麼此時,我們將 fn2 賦值給 fn1 ,我們可以思考下。如果賦值成功了,當我呼叫 fn1 時,其實相當於呼叫 fn2 沒錯吧。

但是,由於 fn1 的函式型別定義僅僅支援兩個引數 a:stringb:number 即可。但是由於我們執行了 fn1 = fn2

呼叫 fn1 時,實際相當於呼叫了 fn2 函式。但是型別定義上來說 fn1 滿足兩個引數傳入即可,而 fn2 是實打實的需要傳入 3 個引數。

那麼此時,如果執行了 fn1 = fn2 當呼叫 fn1 時明顯引數個數會不匹配(由於型別定義不一致)會缺少一個第三個引數,顯然這是不安全的,自然也不是被 TS 允許的。

那麼反過來呢?

```ts let fn1!: (a: string, b: number) => void; let fn2!: (a: string, b: number, c: boolean) => void;

fn2 = fn1; // 正確,被允許 ```

按照剛才的思路來分析,我們將 fn1 賦值給 fn2 。fn2 的型別定義需要支援三個引數的傳入,但實際 fn2 內部指標已經被修改稱為 fn1 的指標。

fn1 在執行時僅僅需要兩個引數 a: string, b: number,顯然 fn2 的型別定義中是滿足這個條件的(當然它還多傳遞了第三個引數 c:boolean,在 JS 中對於函式而言呼叫時的引數個數大於定義時的引數個數是被允許的)。

自然,這是安全的也是被 TS 允許賦值。

就比如上述函式的引數型別賦值就被稱為逆變,引數少(父)的可以賦給引數多(子)的那一個。看起來和型別相容性(多的可以賦給少的)相反,但是通過呼叫的角度來考慮的話恰恰滿足多的可以賦給少的相容性原則。

上述這種函式之間互相賦值,他們的引數型別相容性是典型的逆變


我們再來看一個稍微複雜點的例子來加深所謂逆變的理解:

```ts class Parent {}

// Son繼承了Parent 並且比parent多了一個例項屬性 name class Son extends Parent { public name: string = '19Qingfeng'; }

// GrandSon繼承了Son 在Son的基礎上額外多了一個age屬性 class Grandson extends Son { public age: number = 3; }

// 分別建立父子例項 const son = new Son();

function someThing(cb: (param: Son) => any) { // do some someThing // 注意:這裡呼叫函式的時候傳入的實參是Son cb(Son); }

someThing((param: Grandson) => param); // error someThing((param: Parent) => param); // correct ```

這裡我們定義了三個類,他們之間的關係分別是 Parent 是基類,Son 繼承 Parent ,Grandson 繼承 Son 。

同時我們定義了一個函式,它接受一個 cb 回撥引數作為引數,我們定義了這個回撥函式的型別為接受一個 param 為 Son 例項型別的引數,此時我們不關心它的返回值給一個 any 即可。

注意這裡,我們先用剛才的結論來推導。剛才我們提到過函式的引數的方式被稱為逆變,所以當我們呼叫 someThing 時傳遞的 callback 需要賦給定義 something 函式中的 cb 。

換句話說型別 (param: Grandson) => param 需要賦給 cb: (param: Son) => any,這顯然是不被允許的。

因為逆變的效果函式的引數只允許“從少的賦值給多的”,顯然 Grandson 相較於 Son 來說多了一個 name 屬性少,所以這是不被允許的。

相反,第二個someThing((param: Parent) => param);相當於函式引數重將 Parent 賦給 Son 將少的賦給多的滿足逆變,所以是正確的。

之後我們在嘗試分析為什麼第二個someThing((param: Parent) => param);是正確的。

首先我們需要注意到我們在定義 someThing 函式時,聲明瞭這個函式接受一個 cb 的函式。這個函式接受一個型別為 Son 的引數。

someThing 內部cb 函式宣告時需要滿足 Son 的引數,它會在 cb 函式呼叫時傳入一個 Son 引數的實參

所以當我們傳入 someThing((param: Parent) => param) 時,相當於在 something 函式內部呼叫 (param: Parent) => param 時會根據 someThing 中callback的定義傳入一個 Son 。

那麼此時,我們函式真實呼叫時期望得到是 Parent,但是實際得到了 Son 。Son 是 Parent 的子類涵蓋所有 Parent 的公共屬性方法,自然也是滿足條件的。

反而言之,當我們使用someThing((param: Grandson) => param);,由於 something 定義 cb 的型別傳入 Son,但是真實呼叫 someThing 時,我們確需要一個 Grandson 型別引數的函式,這顯然是不符合的。

關於逆變我用了比較多的篇幅去描述它,我希望通過文章大家都可以對於逆變結合例項來理解並應用它。因為它的確稍微有些繞。

協變

解決了逆變之後,其實協變對於大夥兒來說都是小意思。我們先來看看這個 Demo:

```ts let fn1!: (a: string, b: number) => string; let fn2!: (a: string, b: number) => string | number | boolean;

fn2 = fn1; // correct fn1 = fn2 // error: 不可以將 string|number|boolean 賦給 string 型別 ```

這裡,函式型別賦值相容時函式的返回值就是典型的協變場景,我們可以看到 fn1 函式返回值型別規定為 string,fn2 返回值型別規定為 string | number | boolean 。

顯然 string | number | boolean 是無法分配給 string 型別的,但是 string 型別是滿足 string | number | boolean 其中之一,所以自然可以賦值給 string | number | boolean 組成的聯合型別。

其實這就是協變....當然你也可以嘗試從函式執行角度來解讀協變的概念,比如當 fn1 執行結束要求返回 string , fn2 執行結束後要求返回 string | number | boolean 。

將 fn1 賦給 fn2 ,fn1 要求返回值是 string ,而真實呼叫的 fn1=fn2 相當於呼叫了 fn2 自然 string | number | boolean 無法滿足string型別的要求,所以 TS 會認為這是錯誤的。

待推斷型別

infer 代表待推斷型別,它的必須和 extends 條件約束型別一起使用。

之前,我們在 型別關鍵字中遺留了 infer 關鍵字並沒有展開講述,這裡我們瞭解了所謂的 extends 代表的型別約束之後我們來一起看看所謂 infer 帶來的待推斷型別效果。

在條件型別約束中為我們提供了 infer 關鍵字來提供實現更多的型別可能,它表示我們可以在條件型別中推斷一些暫時無法確定的型別,比如這樣:

ts type Flatten<Type> = Type extends Array<infer Item> ? Item : Type;

上述我們定義了一個 Flatten 型別,它接受一個傳入的泛型 Type ,我們在型別定義內部對於傳入的泛型 Type 進行了條件約束:

  • 如果 Type 滿足 Array<infer Item>,那麼此時返回 Item 型別。

  • 如果 Type 不滿足 Array<infer Item>型別,那麼此時返回 Type 型別。

關於如何理解Array<infer Item>一句話描述就是我們利用 infer 聲明瞭一個數組型別,陣列中值的型別我們並不清楚所以使用 infer 來進行推斷陣列中的值。

比如:

image.png

我們為型別Flatten傳入一個 string 型別,顯然傳入的 string 並不滿足陣列的約束。自然直接返回傳入的 string 型別。

此時我們試試傳入一個數組型別呢:

image.png

可以看到返回的 subType 型別為 string | number 。我們來稍微分析這一過程:

宣告Flatten<[string, number]>時,Flatten 接受到一個 [string,number] 的泛型引數。

顯然 [string,number]是滿足陣列的條件的,Type extends Array<infer Item>

所謂的 Array<infer Item>代表的進行條件判斷時要求前者(Type)必須是一個數組,但是陣列中的型別我並不清楚(或者說可以是任意)。

自然我們使用 infer 關鍵字表示待推斷的型別, infer 後緊跟著型別變數 Item 表示的就是待推斷的陣列元素型別。

我們型別定義時並不能立即確定某些型別,而是在使用型別時來根據條件來推斷對應的型別。之後,因為陣列中的元素可能為 string 也可能為 number,自然在使用型別時 infer Item 會將待推斷的 Item 推斷為 string | number 聯合型別。

需要注意的是 infer 關鍵字型別,必須結合 Conditional Types 條件判斷來使用。

那麼,在條件型別中結合 infer 會幫助我們帶來什麼樣的作用呢?我們一起來看看 infer 的實際用法。

在 TS 中存在一個內建型別 Parameters ,它接受傳入一個函式型別作為泛型引數並且會返回這個函式所有的引數型別組成的元祖。

```ts // 定義函式型別 interface IFn { (age: number, name: string): void; }

// type FnParameters = [age: number, name: string] type FnParameters = Parameters;

let a: FnParameters = [25, '19Qingfeng']; ```

它的內部實現恰恰是利用 infer 來實現的,同學們可以自己嘗試來實現這個內建型別。


ts type MyParameters<T extends (...args: any) => any> = T extends ( ...args: infer R ) => any ? R : never;

其實它的實現非常簡單,定義的 MyParameters 型別中接受一個泛型 T 當傳入 T 時需要滿足它為函式型別的約束。

其次我們在 MyParameters 內部對於 傳入的泛型引數進行了條件判斷,如果滿足條件也就是 T extends ( ...args: infer R ) => any,需要注意的是條件判斷中函式的引數並不是在型別定義時就確認的,函式的引數需要根據傳入的泛型來確認後賦給變數 R 所以使用了 infer R 來表示待推斷的函式引數型別。

那麼此時我會返回滿足條件的函式推斷引數組成的陣列也就是 ...args 的型別 R ,否則則返回 never 。

當然 TS 內部還存在比如 ReturnType 、ThisParameterType 等型別都是基於條件判斷中的 infer 來推斷出結果的,有興趣的朋友可以自行查閱。

日常工作中,我們經常會碰到將元祖轉化成為聯合型別的需求,比如 ['a',1,true] 我們希望快速得到元組中元素的型別應該如何實現呢?

image.png

unknown & any

在 TypeScript 中同樣存在一個高階型別 unknown ,它可以代表任意型別的值,這一點和 any 是非常型別的。

但是我們清楚將型別宣告為 any 之後會跳過任何型別檢查,比如這樣:

```ts let myName: any;

myName = 1

// 這明顯是一個bug myName() ```

而 unknown 和 any 代表的含義完全是不一樣的,雖然 unknown 可以和 any 一樣代表任意型別的值,但是這並不代表它可以繞過 TS 的型別檢查。

```ts let myName: unknown;

myName = 1

// ts error: unknown 無法被呼叫,這被認為是不安全的 myName()

// 使用typeof保護myName型別為function if (typeof myName === 'function') { // 此時myName的型別從unknown變為function // 可以正常呼叫 myName() } ```

通俗來說 unknown 就代表一些並不會繞過型別檢查但又暫時無法確定值的型別,我們在一些無法確定函式引數(返回值)型別中 unknown 使用的場景非常多。比如:

ts // 在不確定函式引數的型別時 // 將函式的引數宣告為unknown型別而非any // TS同樣會對於unknown進行型別檢測,而any就不會 function resultValueBySome(val:unknown) { if (typeof val === 'string') { // 此時 val 是string型別 // do someThing } else if (typeof val === 'number') { // 此時 val 是number型別 // do someThing } // ... }

當然,在描述了 unknown 型別的含義之後,關於 unknown 型別有一個特別重要的點我想和大家強調:

image.png

unknown型別可以接收任意型別的值,但並不支援將unknown賦值給其他型別。

any型別同樣支援接收任意型別的值,同時賦值給其他任意型別(除了never)。

any 和 unknown 都代表任意型別,但是 unknown 只能接收任意型別的值,而 any 除了可以接收任意型別的值,也可以賦值給任意型別(除了 never)。

比如下面這樣:

```ts let a!: any; let b!: unknown;

// 任何型別值都可以賦給any、unknown a = 1; b = 1;

// callback函式接受一個型別為number的引數 function callback(val: number): void {}

// 呼叫callback傳入aaa(any)型別 correct callback(a);

// 呼叫callback傳入b(unknown)型別給 val(number)型別 error // ts Error: 型別“unknown”的引數不能賦給型別“number”的引數 callback(b); ```

當然,對於以後並不確定型別的變數希望大家儘量使用更多的 unknown 來代替 any 讓你的程式碼更加強壯。

寫在結尾

至此,文章對於 TypeScript 的內容就在這裡和大家告一段落了。

感謝每一位看到這裡的小夥伴,其實關於如何精進 TypeScript 功底在我個人看來可以總結為以下兩點:

第一,碰到問題一定是要結合文件多查閱文件(當然 TypeScript 一定是要去嘗試閱讀英文文件,及時你的英語不是那麼好),它的中文文件實在是過於簡陋了。

第二,關於 TS 中的確存在對於普通開發者太多的陌生概念。掌握它最直接的辦法就是去用 TS 在任何你能用到的地方,哪怕只是一個特別小的專案,正所謂所謂熟能生巧嘛。

大家,加油!