一文理解TypeScript中各種高級語法
↓推薦關(guān)注↓
引言
TypeScript 的重要性我不在強調(diào)了,我相信仍然會有大多數(shù)前端開發(fā)者碰到復雜類型一概使用 any 處理。
我寫這篇文章的目的就是為了讓你告別 AnyScript ,文章告別晦澀的概念結(jié)合實例來為你講述一系列 TS 高級用法:分發(fā)、循環(huán)、協(xié)變、逆變、unknown ... 等等之類。讓我們告別枯燥的概念,結(jié)合真實用例來掌握 TypeScript 從此徹底告別 AnyScript !
文章并不會從基礎的 TS 語法開始講解,如果你還不了解什么是 TypeScript 強烈建議閱讀?TS 官方文檔[1]。
泛型
泛型基礎
熟悉 Java 或者 C# 的朋友對于 泛型的概念也許非常了解,關(guān)于泛型的概念這里我并不打算在文章中進行贅述了。
關(guān)于如何解釋泛型,我看到的最好的一句話概括把明確類型的工作推遲到創(chuàng)建對象或調(diào)用方法的時候才去明確的特殊的類型,簡單點來講我們可以將泛型理解成為把類型當作參數(shù)一樣去傳遞。
比如這樣一個簡單的例子:
function?identity<T>(arg:?T):?T?{?
??return?arg;
}
//?調(diào)用identity時傳入name,函數(shù)會自動推導出泛型T為string,自然arg類型為T,返回值類型也為T
const?userName?=?identity('name');
//?同理,當然你也可以顯示聲明泛型
const?id?=?identity<number>(1);?
它在 TS 中的確非常重要,同時也有許多非常優(yōu)秀的文章來講述它的基礎用法。它既重要又基礎,是掌握 TS 高級用法的重中之重。
如果你目前還不是非常了解泛型,那么強烈建議你去閱讀?Generics Type[2]。
接口泛型位置
之所以將接口中的泛型單獨拉出來和大家講述,是因為在日常工作中經(jīng)常會碰到一些同事對于泛型接口位置的不理解。
空口無憑,我們來看看這樣一個簡單的例子:
//?定義一個泛型接口?IPerson表示一個類,它返回的實例對象取決于使用接口時傳入的泛型T
interface?IPerson<T>?{??
??//?因為我們還沒有講到unknown?所以暫時這里使用any?代替??
??new(...args:?unknown[]):?T;
}
function?getInstance<T>(Clazz:?IPerson<T>)?{?
??return?new?Clazz();
}
//?use?it
class?Person?{}
//?TS推斷出函數(shù)返回值是person實例類型
const?person?=?getInstance(Person);
上邊的 Demo 是一個非常再不同不過的例子了,我們定義接口 IPerson 時,這個接口定義了一個泛型參數(shù) T 表示返回的實例類型。
當使用時,我們需要在使用接口時聲明該 T 類型,比如IPerson<T>。
接下來我們在看對比另外一個例子:
//?聲明一個接口IPerson代表函數(shù)
interface?IPerson?{?
??//?此時注意泛型是在函數(shù)中參數(shù)?而非在IPerson接口中?
??<T>(a:?T):?T;
}
//?函數(shù)接受泛型
const?getPersonValue:?IPerson?=?<T>(a:?T):?T?=>?{??
??return?a;
};
//?相當于getPersonValue<number>(2)
getPersonValue(2)
這里上下兩個例子特別像強調(diào)的是關(guān)于泛型接口中泛型的位置是代表完全不同的含義:
- 當泛型出現(xiàn)在接口中時,比如
interface IPerson<T>?代表的是使用接口時需要傳入泛型的類型,比如IPerson<T>。 - 當泛型出現(xiàn)在接口內(nèi)部時,比如第二個例子中的?
IPerson接口代表一個函數(shù),接口本身并不具備任何泛型定義。而接口代表的函數(shù)則會接受一個泛型定義。換句話說接口本身不需要泛型,而在實現(xiàn)使用接口代表的函數(shù)類型時需要聲明該函數(shù)接受一個泛型參數(shù)。
趁熱打鐵,我們來看這樣一個例子:當我們希望實現(xiàn)一個數(shù)組的 forEach 方法時,嘗試使用泛型來實現(xiàn):
//?定義callback遍歷方法?兩種方式?應該采用哪一種?
type?Callback?=?<T>(item:?T)?=>?void
//?第二種聲明方式
type?Callback<T>?=?(item:?T)?=>?void;
const?forEach?=?<T>(arr:?T[],?callback:?Callback)?=>?{?
??for?(let?i?=?0;?i?<?arr.length?-?1;?i++)?{??
????callback(arr[i])?
??}
};
forEach(['1',?2,?3,?'4'],?(item)?=>?{});
關(guān)于 forEach 方法我相信大伙兒都已經(jīng)非常了解了,這里我們嘗試使用 TS 來實現(xiàn)這個方法。此時我們將 callback 類型單獨抽離出來。
上邊的寫法有兩種聲明方式,小伙伴們覺得應該關(guān)于 forEach 中的 callback 類型定義應該采用第幾種呢?第一種 or 第二種?
大家可以結(jié)合上邊的兩個例子自己先稍微思考下。
答案是第二種方式type Callback<T> = (item: T) => void;。
這里有一個非常關(guān)鍵的點需要注意,所謂 TS 是一種靜態(tài)類型檢測,并不會執(zhí)行你的代碼。
我們先來分析第二種方式的類型定義,我稍微將調(diào)用時的代碼補充完整(這樣方便大伙兒理解):
//?item的類型取決于調(diào)用函數(shù)時傳入的類型參數(shù)
type?Callback?=?<T>(item:?T)?=>?void;
const?forEach?=?<T>(arr:?T[],?callback:?Callback)?=>?{?
??for?(let?i?=?0;?i?<?arr.length?-?1;?i++)?{???
????//?這里調(diào)用callback時,ts并不會執(zhí)行我們的代碼。???
????//?換句話說:它并不清楚arr[i]是什么類型???
????callback(arr[i]);?
??}
};
//?所以這里我們并不清楚?callback?定義中的T是什么類型,自然它的類型還是T
forEach(['1',?2,?3,?'4'],?<T>(item:?T)?=>?{});
如果采用第二種聲明方式,在 forEach 內(nèi)部的 callback 函數(shù)調(diào)用時,才會清楚函數(shù)傳入的參數(shù)類型。顯然forEach 調(diào)用時無法正確推斷出 item 的類型定義。
接下來,我們來看看第二種方式:
//?item?的類型取決于使用類型時傳入的泛型參數(shù)
type?Callback<T>?=?(item:?T)?=>?void;
//?在聲明階段就已經(jīng)確定了?callback?接口中的泛型參數(shù)為外部傳入的
const?forEach?=?<T>(arr:?T[],?callback:?Callback<T>)?=>?{?
??for?(let?i?=?0;?i?<?arr.length?-?1;?i++)?{??
????callback(arr[i]);
??}
};
//?自然,我們在調(diào)用forEach時顯式聲明泛型參數(shù)為?string?|?number?類型
//?所以根據(jù)forEach的函數(shù)類型定義時,
//?自然?callback?的?item?也會在定義時被推導為?T?也就是所謂的?string?|?number?類型
forEach<string?|?number>(['1',?2,?3,?'4'],?(item)?=>?{});
所以,這一點在日常開發(fā)中希望小伙伴們一定要特別留意:在泛型接口中泛型的聲明位置不同所產(chǎn)生的效果是完全不同的。
泛型約束
所謂泛型約束,通俗點來講就是約束泛型需要滿足的格式。提到它,有一個非常經(jīng)典的案例:
//?定義方法獲取傳入?yún)?shù)的length屬性
function?getLength<T>(arg:?T)?{??
??//?throw?error:?arr上不存在length屬性?
??return?arg.length;
}
這里,我們定義了一個 getLength 方法,希望函數(shù)獲取傳入?yún)?shù)的 length 屬性。
因為傳入的參數(shù)是不固定的,有可能是 string 、 array 、 arguments 對象甚至一些我們自己定義的?{ name:"19Qingfeng", length: 100 },所以我們?yōu)楹瘮?shù)增加泛型來為函數(shù)增加更加靈活的類型定義。
可是隨之而來的問題來了,那么此時我們在函數(shù)內(nèi)部訪問了 arg.length 屬性。但是此時,arg 所代表的泛型可以是任意類型。
比如我們可以傳入一個 boolean ,那么此時函數(shù)中的泛型 T 代表 boolean 類型,訪問 boolean.length ? 這顯然是一個 bug 。
那么如果解決這個問題呢,當然就提到了所謂的泛型約束 extends 關(guān)鍵字。
我們先來看看如何使用它:
interface?IHasLength?{?
??length:?number;
}
//?利用?extends?關(guān)鍵字在聲明泛型時約束泛型需要滿足的條件
function?getLength<T?extends?IHasLength>(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<boolean>(true)
//?顯然?boolean?類型上并不存在擁有?length?屬性的約束,所以TS會提示語法錯誤getLength(true);?
類型關(guān)鍵字
其實原本一些簡單的類型關(guān)鍵字我并不打算在文章中去闡述的,但是后續(xù)許多高級類型以及高級概念正是基于這些來實現(xiàn)的,所以文章為了照顧一些不是特別熟悉 TS 的小伙伴還是對于這部分特意進行了講述。
keyof 關(guān)鍵字
所謂 keyof 關(guān)鍵字代表它接受一個對象類型作為參數(shù),并返回該對象所有 key 值組成的聯(lián)合類型。
比如:
interface?IProps?{?
??name:?string;
??age:?number;?
??sex:?string;
}
//?Keys?類型為?'name'?|?'age'?|?'sex'?組成的聯(lián)合類型
type?Keys?=?keyof?IProps
看上去非常簡單對吧,需要額外注意的一點是當?keyof any?時候我們會得到什么類型呢?
小伙伴們可以稍微思考下?keyof any?會得到什么樣的類型。
//?Keys?類型為?string?|?number?|?symbol?組成的聯(lián)合類型
type?Keys?=?keyof?any
其實這是非常容易理解,any 可以代表任何類型。那么任何類型的 key 都可能為 string 、 number 或者 symbol 。所以自然 keyof any 為 string | number | symbol 的聯(lián)合類型。
在之后的高級類型中會利用到keyof any的特性,所以這里提前拿出來讓大家預熱下。
在了解了 keyof 關(guān)鍵字之后,讓我們結(jié)合泛型來實現(xiàn)一個簡單的例子來練練手。
比如此時,我們希望實現(xiàn)一個函數(shù)。該函數(shù)希望接受兩個參數(shù),第一個參數(shù)為一個對象object,第二個參數(shù)為該對象的 key 。函數(shù)內(nèi)部通過傳入的 object 以及對應的 key 返回?object[key]?。
function?getValueFromKey(obj:?object,?key:?string)?{?
??//?throw?error?
??//?key的值為string代表它僅僅只被規(guī)定為字符串??
??//?TS無法確定obj中是否存在對應的key
??return?obj[key];
}
顯然,我們直接為參數(shù)聲明類型這是會報錯的。同學們可以結(jié)合剛剛學過的 keyof 關(guān)鍵字配合泛型來思考一下如何消除 TS 的錯誤提示。
//?函數(shù)接受兩個泛型參數(shù)
//?T?代表object的類型,同時T需要滿足約束是一個對象
//?K?代表第二個參數(shù)K的類型,同時K需要滿足約束keyof?T?(keyof?T?代表object中所有key組成的聯(lián)合類型)
//?自然,我們在函數(shù)內(nèi)部訪問obj[key]就不會提示錯誤了
function?getValueFromKey<T?extends?object,?K?extends?keyof?T>(obj:?T,?key:?K)?{?
??return?obj[key];
}
它的實現(xiàn)非常簡單,這里沒有寫出來的同學可以好好看看文章中上述的內(nèi)容。
is 關(guān)鍵字
原本是不打算講述這個基礎概念的,奈何之前在一次面試中因為 is 關(guān)鍵字翻了車哈哈。
面試官問我熟悉 Ts 嗎,答案一定是肯定的。結(jié)果問了我一個 is 關(guān)鍵字代表的含義,當時的我簡直是百思不得其解.. “難道你問的不是 as 嗎”,is 究竟是個什么東西好像從來沒有聽說過。
于是面試結(jié)束后趕快搜了搜,結(jié)果竟然就是業(yè)務中經(jīng)常用到的類型謂詞。。。
所謂 is 關(guān)鍵字其實更多用在函數(shù)的返回值上,用來表示對于函數(shù)返回值的類型保護。
它的用法非常簡單:
//?函數(shù)的返回值類型中?通過類型謂詞?is?來保護返回值的類型
function?isNumber(arg:?any):?arg?is?number?{?
??return?typeof?arg?===?'number'
}
function?getTypeByVal(val:any)?{?
??if?(isNumber(val))?{??
????//?此時由于isNumber函數(shù)返回值根據(jù)類型謂詞的保護??
????//?所以可以斷定如果?isNumber?返回true?那么傳入的參數(shù)?val?一定是?number?類型???
????val.toFixed()?
??}
}
所以,通常我們使用 is 關(guān)鍵字(類型謂詞)在函數(shù)的返回值中,從而對于函數(shù)傳入的參數(shù)進行類型保護。
infer 關(guān)鍵字
infer 關(guān)鍵字我不打算放在這里和大家描述了,我會在下面的內(nèi)容和大家逐步切入對應的關(guān)鍵字。
TS 高級概念
分發(fā)
在講述分發(fā)的概念,我會先和你聊聊 TS 中的 Conditional Types (條件類型)。
因為大多數(shù)高級類型都是基于條件類型,同時分發(fā)的概念也和 Conditional Types 息息相關(guān),所以我們先來看看所謂的 Conditional Types 究竟是什么。
type?isString<T>?=?T?extends?string???true?:?false;
//?a?的類型為?true
let?a:?isString<'a'>
//?b?的類型為?false
let?b:?isString<1>;
上邊我們通過 type 關(guān)鍵字定義了一個所謂的 isString 類型,它接受一個泛型參數(shù) T 。
isString 類型內(nèi)部通過 extends 關(guān)鍵字結(jié)合 ? 和 : 實現(xiàn)了所謂的 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 時,你可以為它傳入任意類型作為泛型參數(shù)的實現(xiàn)。但是 isString 類型內(nèi)部會對于傳入的泛型類型進行判斷,如果 T 滿足 string 的約束條件,那么返回類型 true,反過來則是 false 。
- 其次,需要注意的是條件類型?
a extends b ? c : d?僅僅支持在 type 關(guān)鍵字中使用。
在了解了泛型約束之后,我們在回到所謂分發(fā)的概念上來。
一起來看看這樣一個例子:
type?GetSomeType<T?extends?string?|?number>?=?T?extends?string???'a'?:?'b';
let?someTypeOne:?GetSomeType<string>?//?someTypeone?類型為?'a'
let?someTypeTwo:?GetSomeType<number>?//?someTypeone?類型為?'b'
let?someTypeThree:?GetSomeType<string?|?number>;?//?what???
這里我們定義了一個 GetSomeType 的類型,它接受一個泛型參數(shù) T 。這個泛型參數(shù) T 在傳入時需要滿足為 string 和 number 的聯(lián)合類型的約束。(換句話說,要么為 string 要么為 number 要么為 string | number)。
- 首先,someTypeOne 變量的類型為?
GetSomeType<string>。
因為我們?yōu)?someTypeOne 定義時傳入了 string 的類型參數(shù),所以按照條件類型來判斷,string extends string?明顯是滿足的,所以返回類型 'a'。
- 同理,someTypeTwo 變量的類型也會被推斷為 'b',這個過程我就不在累贅了。
那么重點來了,someTypeThree 定義時的類型 GetSomeType<'string' | 1> 我們傳入的泛型參數(shù)為聯(lián)合類型時 'string' | 1 時,它會的到什么類型呢?
首先不難想象,我們按照正常邏輯來思考。'string' | 1 一定是不滿足?T extends string,因為一個?'string' | 1?的聯(lián)合類型一定是無法和 string 類型做兼容的。
那么按照我們之前的邏輯來梳理,理所應當 someTypeThree 的類型應該是 'b' 對吧。
可是結(jié)果真如那么簡單的話,那么我還舉出來這個例子做什么呢?

很驚訝吧,someTypeThree 的類型竟然被推導成為了 'a' | 'b' 組成的聯(lián)合類型,那么為什么會這樣呢。
其實這就是所謂分發(fā)在搗鬼。
我們拋開晦澀的概念來解讀分發(fā),結(jié)合上邊的 Demo 來說所謂的分發(fā)簡單來說就是分別使用 string 和 number 這兩個類型進入 GetSomeType 中進行判斷,最終返回兩次類型結(jié)果組成的聯(lián)合類型。
當然,你可以在使用 GetSomeType 你可以傳入n個類型組成的聯(lián)合類型作為泛型參數(shù),同理它會進行進入 GetSomeType 類型中進行 n 次分發(fā)判斷。
//?你可以這樣理解分發(fā)
//?偽代碼:GetSomeType<string | number>?= GetSomeType<string>?| GetSomeType<number>
let?someTypeThree:?GetSomeType<string?|?number>
自然我們就得到的 someTypeThree 類型為 "a" | "b" 。
相信看到這里的同學都已經(jīng)能理解分發(fā)代表的是什么含義。
那么,什么情況下會產(chǎn)生分發(fā)呢?滿足分發(fā)需要一定的條件,我們來一起看看:
- 首先,毫無疑問分發(fā)一定是需要產(chǎn)生在 extends 產(chǎn)生的類型條件判斷中,并且是前置類型。
比如T extends string | number ? 'a' : 'b';?那么此時,產(chǎn)生分發(fā)效果的也只有 extends 關(guān)鍵字前的 T 類型,string | number 僅僅代表一種條件判斷。
- 其次,分發(fā)一定是要滿足聯(lián)合類型,只有聯(lián)合類型才會產(chǎn)生分發(fā)(其他類型無法產(chǎn)生分發(fā)的效果,比如 & 交集中等等)。
- 最后,分發(fā)一定要滿足所謂的裸類型中才會產(chǎn)生效果。
這里的裸類型稍微和大家解釋下,比如這樣:
//?此時的T并不是一個單獨的”裸類型“T?而是?[T]
type?GetSomeType<T?extends?string?|?number?|?[string]>?=?[T]?extends?string[]??
????'a'?
??:?'b';
??
//?即使我們修改了對應的類型判斷,仍然不會產(chǎn)生所謂的分發(fā)效果。因為[T]并不是一個裸類型
//?只會產(chǎn)生一次判斷??[string]?|?number?extends?string[]????'a'?:?'b'
//?someTypeThree?仍然只有?'b'?類型?,如果進行了分發(fā)的話那么應該是?'a'?|?'b'
let?someTypeThree:?GetSomeType<[string]?|?number>;
同樣,在了解了分發(fā)的概念和清楚了如何會產(chǎn)生分發(fā)的效果后。趁熱打鐵我們來看看利用分發(fā)我們可以實現(xiàn)什么樣的效果:
在 TypeScript 內(nèi)部擁有一個高級內(nèi)置類型 Exclude 意為排除,它的用法如下:
type?TypeA?=?string?|?number?|?boolean?|?symbol;
//?ExcludeSymbolType?類型為?string?|?number?|?boolean,排除了symbol類型
type?ExcludeSymbolType?=?Exclude<TypeA,?symbol>;
用法非常簡單,Exclude 內(nèi)置類型會接受兩個類型泛型參數(shù)。它會構(gòu)造一個新的類型,這個類型會排除所有 TypeA 類型中滿足 symbol 的類型。
那么,如果讓你來實現(xiàn)一個 Exclude 內(nèi)置類型,你會如何實現(xiàn)呢?同學們可以結(jié)合分發(fā)自行思考下。
如果沒有想出來的小伙伴,強烈建議在重新好好溫習一下分發(fā)這個章節(jié)。
type?TypeA?=?string?|?number?|?boolean?|?symbol;
type?MyExclude<T,?K>?=?T?extends?K???never?:?T;
//?ExcludeSymbolType?類型為?string?|?number?|?boolean,排除了symbol類型
type?ExcludeSymbolType?=?MyExclude<TypeA,?symbol?|?boolean>;
其實它的實現(xiàn)非常簡單,上述我們通過分發(fā)來實現(xiàn)了對應的 MyExclude 類型。
MyExclude 類型接受兩個泛型參數(shù),因為?T extends K ? never : T?中 T 滿足裸類型并且在 extends 關(guān)鍵字前。
同時,我們傳入的 TypeA 為聯(lián)合類型,那么滿足分發(fā)的所有條件。則會產(chǎn)生分發(fā)效果,也就是說會將聯(lián)合類型 TypeA 中所有的單個類型依次進入?T extends K ? never : T;?去判斷。
當滿足條件時,也就是?T extends symbol | boolean?時,此時會得到 never 。(這里的 never 代表的也就是一個無法達到的類型,不會產(chǎn)生任何效果),自然就會被忽略。
而如果不滿足?T extends symbol | boolean?則會被記錄,最終返回不滿足?T extends symbol | boolean?的所有類型組成的聯(lián)合類型,也就是所謂的?string | number?。
當然和 Exclude 相反效果的內(nèi)置類型?Extract[3]、NonNullable[3]也是基于分發(fā)實現(xiàn)的,有興趣的小伙伴可以自行查閱實現(xiàn)。
循環(huán)
TypeScript 中同樣存在對于類型的循環(huán)語法(Mapping Type),通過我們可以通過 in 關(guān)鍵字配合聯(lián)合類型來對于類型進行迭代。比如這樣:
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?};
其實相對來說循環(huán)關(guān)鍵字 in 比較簡單,上述代碼我們聲明了一個所謂的 IPropsKey 的類型:
首先可以看到這個類型是一個對象,對象中的 key 為?[]?包裹的可計算值,value 為 boolean。
keyof IProps?我們在之前提到過它會返回 IProps 所有 key 組成的聯(lián)合類型,也就是?'name' | 'age' | 'highSchool' | 'university'?。
而?[K in keyof IProps]?正是我們在類型內(nèi)部聲明了一個變量 K 。
你可以理解為 in 關(guān)鍵字的作用類似于 for 循環(huán),它會循環(huán) keyof IProps 這個聯(lián)合類型中的每一項類型,同時在每一次循環(huán)中將對應的類型賦值給 K 。
最終,通過一次一次循環(huán)我們到了最終的新類型?interface IProps { name: string; age: number; highSchool: string; university: string; }?。
那么在 TS 中我們可以利用循環(huán)的特性來做什么呢?不知道大家有沒有用到 Partial 之類的內(nèi)置類型。
所謂的 Partial 功能非常簡單:它會構(gòu)造一個新的類型,這個類型會將之前類型中的所有屬性都變?yōu)榭蛇x。
比如這樣:

可以看到我們通過 Partial 傳入 IInfo 類型,它返回一個新類型 OptinalInfo,OptinalInfo 會將 IInfo 中所有的屬性都變?yōu)榭蛇x類型。
同樣,大家可以自己動手來實現(xiàn)一下它。其實結(jié)合我們剛剛說到的循環(huán)來實現(xiàn)會非常簡單。
interface?IInfo?{?
??name:?string;
??age:?number;
}
type?MyPartial<T>?=?{?[K?in?keyof?T]?:?T[K]?};
type?OptionalInfo?=?MyPartial<IInfo>;
看起來非常簡單對吧,我們通過 in 循環(huán)傳入的 IInfo,同時構(gòu)造了一個新的類型它的 key 和 IInfo 是一模一樣的。
只不過僅僅是所有屬性 key 是可選的,而非必填。
當然需要注意的是我們剛才提到的所有關(guān)鍵字,比如 extends 進行條件判斷或者 in 進行類型循環(huán)時,僅僅支持在 type 類型聲明中使用,并不可以在 interface 中使用,這也是 type 和 interface 聲明的一個不同。
當然,還有許多內(nèi)置類型同樣利用了循環(huán),比如 Required、Readonly 等等。
同學們可以思考下如果讓你實現(xiàn)一個 Required 應該如何實現(xiàn),它會利用到一些Mapping Modifiers[4](映射修飾符),有興趣的朋友可以實現(xiàn)一下練練手。
當然在循環(huán)的最后,我們來思考另一個問題。其實你會發(fā)現(xiàn)無論是 TS 內(nèi)置的 Partial 還是我們剛剛自己實現(xiàn)的 Partial ,它僅僅對象中一層的轉(zhuǎn)化并不能遞歸處理。
比如說:
interface?IInfo?{?
??name:?string;?
??age:?number;??
??school:?{??
????middleSchool:?string;???
????highSchool:?string;??
????university:?string;?
??}
}
type?OptionalInfo?=?Partial<IInfo>;

可以看到利用 Partial 關(guān)鍵字僅僅對于對象類型中的最外層進行了可選標記。
但是對于內(nèi)層嵌套類型比如 school 仍是一個對象類型,那么此時是無法深度進入 school 類型中進行標記的。
那么假如此時我有需求希望實現(xiàn)深度可選,應該如何做呢?大家可以往上邊提到過的條件判斷和循環(huán)結(jié)合來考慮下。
interface?IInfo?{?
??name:?string;??
??age:?number;?
??school:?{???
????middleSchool:?string;???
????highSchool:?string;??
????university:?string;?
??};
}
//?其實實現(xiàn)很簡單,首先我們在構(gòu)造新的類型value時
//?利用?extends?條件判斷新的類型value是否為?object?
//?如果是?->?那么我仍然利用?deepPartial<T[K]>?進行包裹遞歸可選處理
//?如果不是?->?普通類型直接返回即可
type?deepPartial<T>?=?{?
??[K?in?keyof?T]?:?T[K]?extends?object???deepPartial<T[K]>?:?T[K];
};
type?OptionalInfo?=?deepPartial<IInfo>;
let?value:?OptionalInfo?=?{?
??name:?'1',?
??school:?{??
????middleSchool:'xian'?
??},
};
不賣關(guān)子了,如果對于文章之前的知識點你都掌握了,那么我相信實現(xiàn)這個功能對你來說簡直是小菜一碟。
其實看到這里,TS 內(nèi)置的一些類型比如 Pick 、 Omit 大家都可以嘗試自己去實現(xiàn)下了。我們之前說到了知識點已經(jīng)可以完全涵蓋這些內(nèi)置類型的實現(xiàn)。
逆變
許多不是很熟悉 TS 的朋友對于逆變和協(xié)變的概念會感到莫名的恐懼,沒關(guān)系。它們僅僅代表闡述表現(xiàn)的概念而已,放心我們并不會從概念入手而是通過實例來逐步為你揭開它的面紗。
首先,我們先來思考這樣一個場景:
let?a!:?{?a:?string;?b:?number?};
let?b!:?{?a:?string?};
b?=?a
我們都清楚 TS 屬于靜態(tài)類型檢測,所謂類型的賦值是要保證安全性的。
通俗來說也就是多的可以賦值給少的,上述代碼因為 a 的類型定義中完全包括 b 的類型定義,所以 a 類型完全是可以賦值給 b 類型,這被稱為類型兼容性。
之后,我們再來思考這樣一段代碼:
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 允許不同類型進行互相賦值(只需要父/子集關(guān)系),那么明明 fn2 的參數(shù)包括了所有的 fn1 為什么會報錯?
上述的問題,其實和剛剛沒有什么本質(zhì)區(qū)別。我們來換一個角度來理解這個問題:
針對于 fn1 聲明時,函數(shù)類型需要接受兩個參數(shù),換句話說調(diào)用 fn1 時我需要支持兩個參數(shù)的傳入分別是?a:string和b:number。
同理 fn2 函數(shù)定義時,定義了三個參數(shù)那么調(diào)用 fn2 時自然也需要傳入三個參數(shù)。
那么此時,我們將 fn2 賦值給 fn1 ,我們可以思考下。如果賦值成功了,當我調(diào)用 fn1 時,其實相當于調(diào)用 fn2 沒錯吧。
但是,由于 fn1 的函數(shù)類型定義僅僅支持兩個參數(shù)?a:string和b:number?即可。但是由于我們執(zhí)行了?fn1 = fn2。
調(diào)用 fn1 時,實際相當于調(diào)用了 fn2 函數(shù)。但是類型定義上來說 fn1 滿足兩個參數(shù)傳入即可,而 fn2 是實打?qū)嵉男枰獋魅?3 個參數(shù)。
那么此時,如果執(zhí)行了 fn1 = fn2 當調(diào)用 fn1 時明顯參數(shù)個數(shù)會不匹配(由于類型定義不一致)會缺少一個第三個參數(shù),顯然這是不安全的,自然也不是被 TS 允許的。
那么反過來呢?
let?fn1!:?(a:?string,?b:?number)?=>?void;
let?fn2!:?(a:?string,?b:?number,?c:?boolean)?=>?void;
fn2?=?fn1;?//?正確,被允許
按照剛才的思路來分析,我們將 fn1 賦值給 fn2 。fn2 的類型定義需要支持三個參數(shù)的傳入,但實際 fn2 內(nèi)部指針已經(jīng)被修改稱為 fn1 的指針。
fn1 在執(zhí)行時僅僅需要兩個參數(shù)?a: string, b: number,顯然 fn2 的類型定義中是滿足這個條件的(當然它還多傳遞了第三個參數(shù)?c:boolean,在 JS 中對于函數(shù)而言調(diào)用時的參數(shù)個數(shù)大于定義時的參數(shù)個數(shù)是被允許的)。
自然,這是安全的也是被 TS 允許賦值。
就比如上述函數(shù)的參數(shù)類型賦值就被稱為逆變,參數(shù)少(父)的可以賦給參數(shù)多(子)的那一個??雌饋砗皖愋图嫒菪裕ǘ嗟目梢再x給少的)相反,但是通過調(diào)用的角度來考慮的話恰恰滿足多的可以賦給少的兼容性原則。
上述這種函數(shù)之間互相賦值,他們的參數(shù)類型兼容性是典型的逆變[5]。
我們再來看一個稍微復雜點的例子來加深所謂逆變的理解:
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;
}
//?分別創(chuàng)建父子實例
const?son?=?new?Son();
function?someThing(cb:?(param:?Son)?=>?any)?{?
??//?do?some?someThing?
??//?注意:這里調(diào)用函數(shù)的時候傳入的實參是Son
??cb(Son);
}
someThing((param:?Grandson)?=>?param);?//?error
someThing((param:?Parent)?=>?param);?//?correct
這里我們定義了三個類,他們之間的關(guān)系分別是 Parent 是基類,Son 繼承 Parent ,Grandson 繼承 Son 。
同時我們定義了一個函數(shù),它接受一個 cb 回調(diào)參數(shù)作為參數(shù),我們定義了這個回調(diào)函數(shù)的類型為接受一個 param 為 Son 實例類型的參數(shù),此時我們不關(guān)心它的返回值給一個 any 即可。
注意這里,我們先用剛才的結(jié)論來推導。剛才我們提到過函數(shù)的參數(shù)的方式被稱為逆變,所以當我們調(diào)用 someThing 時傳遞的 callback 需要賦給定義 something 函數(shù)中的 cb 。
換句話說類型?(param: Grandson) => param?需要賦給?cb: (param: Son) => any,這顯然是不被允許的。
因為逆變的效果函數(shù)的參數(shù)只允許“從少的賦值給多的”,顯然 Grandson 相較于 Son 來說多了一個 name 屬性少,所以這是不被允許的。
相反,第二個someThing((param: Parent) => param);相當于函數(shù)參數(shù)重將 Parent 賦給 Son 將少的賦給多的滿足逆變,所以是正確的。
之后我們在嘗試分析為什么第二個someThing((param: Parent) => param);是正確的。
首先我們需要注意到我們在定義 someThing 函數(shù)時,聲明了這個函數(shù)接受一個 cb 的函數(shù)。這個函數(shù)接受一個類型為 Son 的參數(shù)。
someThing 內(nèi)部cb 函數(shù)聲明時需要滿足 Son 的參數(shù),它會在 cb 函數(shù)調(diào)用時傳入一個 Son 參數(shù)的實參。
所以當我們傳入?someThing((param: Parent) => param)?時,相當于在 something 函數(shù)內(nèi)部調(diào)用?(param: Parent) => param?時會根據(jù) someThing 中callback的定義傳入一個 Son 。
那么此時,我們函數(shù)真實調(diào)用時期望得到是 Parent,但是實際得到了 Son 。Son 是 Parent 的子類涵蓋所有 Parent 的公共屬性方法,自然也是滿足條件的。
反而言之,當我們使用someThing((param: Grandson) => param);?,由于 something 定義 cb 的類型傳入 Son,但是真實調(diào)用 someThing 時,我們確需要一個 Grandson 類型參數(shù)的函數(shù),這顯然是不符合的。
關(guān)于逆變我用了比較多的篇幅去描述它,我希望通過文章大家都可以對于逆變結(jié)合實例來理解并應用它。因為它的確稍微有些繞。
協(xié)變
解決了逆變之后,其實協(xié)變對于大伙兒來說都是小意思。我們先來看看這個 Demo:
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?類型
這里,函數(shù)類型賦值兼容時函數(shù)的返回值就是典型的協(xié)變場景,我們可以看到 fn1 函數(shù)返回值類型規(guī)定為 string,fn2 返回值類型規(guī)定為 string | number | boolean 。
顯然 string | number | boolean 是無法分配給 string 類型的,但是 string 類型是滿足 string | number | boolean 其中之一,所以自然可以賦值給 string | number | boolean 組成的聯(lián)合類型。
其實這就是協(xié)變....當然你也可以嘗試從函數(shù)運行角度來解讀協(xié)變的概念,比如當 fn1 運行結(jié)束要求返回 string , fn2 運行結(jié)束后要求返回 string | number | boolean 。
將 fn1 賦給 fn2 ,fn1 要求返回值是 string ,而真實調(diào)用的 fn1=fn2 相當于調(diào)用了 fn2 自然 string | number | boolean 無法滿足string類型的要求,所以 TS 會認為這是錯誤的。
待推斷類型
infer 代表待推斷類型,它的必須和 extends 條件約束類型一起使用。
之前,我們在 類型關(guān)鍵字中遺留了 infer 關(guān)鍵字并沒有展開講述,這里我們了解了所謂的 extends 代表的類型約束之后我們來一起看看所謂 infer 帶來的待推斷類型效果。
在條件類型約束中為我們提供了 infer 關(guān)鍵字來提供實現(xiàn)更多的類型可能,它表示我們可以在條件類型中推斷一些暫時無法確定的類型,比如這樣:
type?Flatten<Type>?=?Type?extends?Array<infer?Item>???Item?:?Type;
上述我們定義了一個 Flatten 類型,它接受一個傳入的泛型 Type ,我們在類型定義內(nèi)部對于傳入的泛型 Type 進行了條件約束:
- 如果 Type 滿足?
Array<infer Item>,那么此時返回 Item 類型。 - 如果 Type 不滿足?
Array<infer Item>類型,那么此時返回 Type 類型。
關(guān)于如何理解?Array<infer Item>,一句話描述就是我們利用 infer 聲明了一個數(shù)組類型,數(shù)組中值的類型我們并不清楚所以使用 infer 來進行推斷數(shù)組中的值。
比如:

我們?yōu)轭愋虵latten傳入一個 string 類型,顯然傳入的 string 并不滿足數(shù)組的約束。自然直接返回傳入的 string 類型。
此時我們試試傳入一個數(shù)組類型呢:

可以看到返回的 subType 類型為 string | number 。我們來稍微分析這一過程:
聲明Flatten<[string, number]>時,F(xiàn)latten 接受到一個?[string,number]?的泛型參數(shù)。
顯然?[string,number]是滿足數(shù)組的條件的,Type extends Array<infer Item>。
所謂的?Array<infer Item>代表的進行條件判斷時要求前者(Type)必須是一個數(shù)組,但是數(shù)組中的類型我并不清楚(或者說可以是任意)。
自然我們使用 infer 關(guān)鍵字表示待推斷的類型, infer 后緊跟著類型變量 Item 表示的就是待推斷的數(shù)組元素類型。
我們類型定義時并不能立即確定某些類型,而是在使用類型時來根據(jù)條件來推斷對應的類型。之后,因為數(shù)組中的元素可能為 string 也可能為 number,自然在使用類型時 infer Item 會將待推斷的 Item 推斷為 string | number 聯(lián)合類型。
需要注意的是 infer 關(guān)鍵字類型,必須結(jié)合 Conditional Types 條件判斷來使用。
那么,在條件類型中結(jié)合 infer 會幫助我們帶來什么樣的作用呢?我們一起來看看 infer 的實際用法。
在 TS 中存在一個內(nèi)置類型 Parameters ,它接受傳入一個函數(shù)類型作為泛型參數(shù)并且會返回這個函數(shù)所有的參數(shù)類型組成的元祖。
//?定義函數(shù)類型
interface?IFn?{?
??(age:?number,?name:?string):?void;
}
//?type?FnParameters?=?[age:?number,?name:?string]
type?FnParameters?=?Parameters<IFn>;
let?a:?FnParameters?=?[25,?'19Qingfeng'];
它的內(nèi)部實現(xiàn)恰恰是利用 infer 來實現(xiàn)的,同學們可以自己嘗試來實現(xiàn)這個內(nèi)置類型。
type?MyParameters<T?extends?(...args:?any)?=>?any>?=?T?extends?(??
??...args:?infer?R
)?=>?any?
????R?
??:?never;
其實它的實現(xiàn)非常簡單,定義的 MyParameters 類型中接受一個泛型 T 當傳入 T 時需要滿足它為函數(shù)類型的約束。
其次我們在 MyParameters 內(nèi)部對于 傳入的泛型參數(shù)進行了條件判斷,如果滿足條件也就是?T extends ( ...args: infer R ) => any,需要注意的是條件判斷中函數(shù)的參數(shù)并不是在類型定義時就確認的,函數(shù)的參數(shù)需要根據(jù)傳入的泛型來確認后賦給變量 R 所以使用了 infer R 來表示待推斷的函數(shù)參數(shù)類型。
那么此時我會返回滿足條件的函數(shù)推斷參數(shù)組成的數(shù)組也就是 ...args 的類型 R ,否則則返回 never 。
當然 TS 內(nèi)部還存在比如 ReturnType 、ThisParameterType 等類型都是基于條件判斷中的 infer 來推斷出結(jié)果的,有興趣的朋友可以自行查閱。
日常工作中,我們經(jīng)常會碰到將元祖轉(zhuǎn)化成為聯(lián)合類型的需求,比如?['a',1,true]?我們希望快速得到元組中元素的類型應該如何實現(xiàn)呢?

unknown & any
在 TypeScript 中同樣存在一個高級類型 unknown ,它可以代表任意類型的值,這一點和 any 是非常類型的。
但是我們清楚將類型聲明為 any 之后會跳過任何類型檢查,比如這樣:
let?myName:?any;
myName?=?1
//?這明顯是一個bug
myName()
而 unknown 和 any 代表的含義完全是不一樣的,雖然 unknown 可以和 any 一樣代表任意類型的值,但是這并不代表它可以繞過 TS 的類型檢查。
let?myName:?unknown;
myName?=?1
//?ts?error:?unknown?無法被調(diào)用,這被認為是不安全的
myName()
//?使用typeof保護myName類型為function
if?(typeof?myName?===?'function')?{?
??//?此時myName的類型從unknown變?yōu)?span style="color:rgb(198,120,221);">function??
??//?可以正常調(diào)用?
??myName()
}
通俗來說 unknown 就代表一些并不會繞過類型檢查但又暫時無法確定值的類型,我們在一些無法確定函數(shù)參數(shù)(返回值)類型中 unknown 使用的場景非常多。比如:
//?在不確定函數(shù)參數(shù)的類型時
//?將函數(shù)的參數(shù)聲明為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 類型的含義之后,關(guān)于 unknown 類型有一個特別重要的點我想和大家強調(diào):

unknown類型可以接收任意類型的值,但并不支持將unknown賦值給其他類型。
any類型同樣支持接收任意類型的值,同時賦值給其他任意類型(除了never)。
any 和 unknown 都代表任意類型,但是 unknown 只能接收任意類型的值,而 any 除了可以接收任意類型的值,也可以賦值給任意類型(除了 never)。
比如下面這樣:
let?a!:?any;
let?b!:?unknown;
//?任何類型值都可以賦給any、unknown
a?=?1;
b?=?1;
//?callback函數(shù)接受一個類型為number的參數(shù)
function?callback(val:?number):?void?{}
//?調(diào)用callback傳入aaa(any)類型?correct
callback(a);
//?調(diào)用callback傳入b(unknown)類型給?val(number)類型?error
//?ts?Error:?類型“unknown”的參數(shù)不能賦給類型“number”的參數(shù)callback(b);
當然,對于以后并不確定類型的變量希望大家盡量使用更多的 unknown 來代替 any 讓你的代碼更加強壯。
寫在結(jié)尾
至此,文章對于 TypeScript 的內(nèi)容就在這里和大家告一段落了。
感謝每一位看到這里的小伙伴,其實關(guān)于如何精進 TypeScript 功底在我個人看來可以總結(jié)為以下兩點:
第一,碰到問題一定是要結(jié)合文檔多查閱文檔(當然 TypeScript 一定是要去嘗試閱讀英文文檔,及時你的英語不是那么好),它的中文文檔實在是過于簡陋了。
第二,關(guān)于 TS 中的確存在對于普通開發(fā)者太多的陌生概念。掌握它最直接的辦法就是去用 TS 在任何你能用到的地方,哪怕只是一個特別小的項目,正所謂所謂熟能生巧嘛。
參考資料
[1]TS 官方文檔:?https://www.typescriptlang.org/docs/handbook/2/basic-types.html
[2]Generics Type:?https://www.typescriptlang.org/docs/handbook/2/generics.html
[3]Extract:??https://www.typescriptlang.org/docs/handbook/utility-types.html#extracttype-union
[4]Mapping Modifiers:?https://www.typescriptlang.org/docs/handbook/2/mapped-types.html#mapping-modifiers
[5]逆變:?https://zh.wikipedia.org/wiki/協(xié)變與逆變
作者:19組清風
https://juejin.cn/post/7089809919251054628
關(guān)注我,一起攜手進階
