10個幫助你捕獲更多Bug的TypeScript建議
1. 對TypeScript提供運行時檢查的思考
有一個對TypeScript常見的誤解是:一個變量只要標注了類型,那么它總是會檢查自己的數(shù)據(jù)類型是否與我們的預(yù)期一致。
與該誤解相呼應(yīng)的想法會認為:對一個從后端返回的對象進行類型標注可以在代碼運行時執(zhí)行檢查來確保對象類型的正確性。
然而這個想法是錯誤的!因為TypeScript最終是被編譯成JavaScript代碼,并且瀏覽器中運行的也是JavaScript。此時(譯者注:運行時)所有的類型信息都丟失了,所以TypeScript無法自動驗證類型。
理解這一點的一個好方法是查看編譯后的代碼:
- interface Person {
- name: string;
- age: number;
- }
- function fetchFromBackend(): Promise<Person> {
- return fetch('http://example.com')
- .then((res) => res.json())
- }
- // 編譯后
- function fetchFromBackend() {
- return fetch('http://example.com')
- .then(function(res) {
- return res.json();
- })
- }
可以看到接口定義在編譯后已經(jīng)完全消失了,而且這里也不會有任何驗證性的代碼。
不過你最好可以自己去執(zhí)行運行時校驗,許多庫(譯者注:io-ts)能幫你做到這點。不過,請記住,這一定會帶來性能開銷。
* 考慮對所有外部提供的對象執(zhí)行運行時檢查(例如從后端獲取的對象,JSON反序列化的對象等)
2. 不要將類型定義為any
使用TypeScript時,可以將變量或函數(shù)參數(shù)的類型聲明為any,但是這樣做也意味著該變量脫離了類型安全保障。
不過聲明為any類型也會有好處,在某種場景下很有幫助(例如將類型逐步添加到現(xiàn)有的JavaScript代碼庫中,譯者注:一般是將代碼庫從js升級到ts時)。但是它也像一個逃生艙口,會大大降低代碼的類型安全性。
當類型安全涵蓋盡可能多的代碼時,它是最有效的。否則,安全網(wǎng)中會存在漏洞,漏洞可能會通過漏洞傳播。例如:如果函數(shù)返回any,則使用其返回值的所有表達式類型也將變成any。
所以你應(yīng)該盡量避免使用any類型。幸運的是,TypeScript3.0引入了類型安全的替代方案——unknown??梢詫⑷魏沃蒂x給unknown類型的變量,但是不能將unknown類型的變量的值賦給任何變量(這點不同于any)。
如果你的函數(shù)返回的是unknown類型的值,則調(diào)用方需要執(zhí)行檢查(使用類型保護),或至少將值顯式轉(zhuǎn)換為某個特定類型。(譯者注:如果對這段不理解,可以參考下這篇文章,unknown 類型 中的示例部分)
- let foo: any;
- // anything can be assigned to foo
- foo = 'abc';
- // foo can be assigned to anything
- const x: number = foo;
- let bar: unknown;
- // anything can be assigned to bar
- bar = 'abc';
- // COMPILE ERROR! Type 'unknown' is not assignable to type 'number'.
- const y: number = bar;
使用unknown類型有時會有些麻煩,但是這也會讓代碼更易于理解,并且讓你在開發(fā)時更加注意。
另外,你需要開啟noImplicitAny,每當編譯器推斷某個值的類型為any時就會拋出錯誤。換句話說,它讓你顯式的標注出所有會出現(xiàn)any的場景。
盡管最終目標還是消除有any的情況,但明確申明any仍然是有益的:例如在code review時可以更容易捕獲他們。
* 不要使用any類型并開啟noImplicitAny
3. 開啟strictNullChecks
你已經(jīng)見過多少次這樣的報錯信息了?
- TypeError: undefined is not an object
我打賭有很多次了,JavaScript(甚至是軟件編程)中最常見的bug來源之一就是忘記處理空值。
在JavaScript中用null或undefined來表示空值。開發(fā)者們經(jīng)常樂觀的認為給定的變量不會是空的,于是就忘記處理空值的情況。
- function printName(person: Person) {
- console.log(person.name.toUpperCase());
- }
- // RUNTIME ERROR! TypeError: undefined is not an object
- // (evaluating 'person.name')
- printName(undefined);
通過開啟strictNullChecks,編譯器會迫使你去做相關(guān)的檢查,這對防止出現(xiàn)這種常見問題起到了重要的作用。
默認情況下,typescript的每個類型都包含null和undefined這兩個值。也就是說,null和undefined可以被賦值給任意類型的任何變量。
而開啟strictNullChecks會更改該行為。由于無法將undefined作為Person類型的參數(shù)傳遞,因此下方的代碼會在編譯時報錯。
- // COMPILE ERROR!
- // Argument of type 'undefined' is not assignable to parameter of type 'Person'. printName(undefined);
那如果你確實就想將undefined傳遞給printName怎么辦?那你可以調(diào)整類型簽名,但是仍然會要求你處理undefined的情況。
- function printName(person: Person | undefined) {
- // COMPILE ERROR!
- // Object is possibly 'undefined'.
- console.log(person.name.toUpperCase());
- }
你可以通過確保person是被定義的來修復(fù)這個錯誤:
- function printName(person: Person | undefined) {
- if (person) {
- console.log(person.name.toUpperCase());
- }
- }
不幸的是,strictNullChecks默認是不開啟的,我們需要在tsconfig.json中進行配置。
另外,strictNullChecks是更通用的嚴格模式的一部分,可以通過strict標志啟用它。你絕對應(yīng)該這樣做!因為編譯器的設(shè)置越嚴格,你就可以盡早發(fā)現(xiàn)更多bug。
* 始終開啟strictNullChecks
4. 開啟strictPropertyInitialization
strictPropertyInitialization是屬于嚴格模式標志集的另一個標志。尤其在使用Class時開啟strictPropertyInitialization很重要,它其實有點像是對strictNullChecks的擴展。
如果不開啟strictPropertyInitialization的話,TS會允許以下的代碼:
- class Person {
- name: string;
- sayHello() {
- // RUNTIME ERROR!
- console.log( `Hello from ${this.name.toUpperCase()}`);
- }
- }
這里有個很明顯的問題:this.name沒有被初始化,因此在運行時調(diào)用sayHello就會報錯。
造成這個錯誤的根本原因是這個屬性沒有在構(gòu)造函數(shù)里或使用屬性初始化器賦值,所以它(至少在最初)是undefined,因此他的類型就會變成string | undefined。
開啟strictPropertyInitialization會提示以下錯誤:
- Property 'name' has no initializer and is not assigned in the constructor.
當然,如果你在構(gòu)造函數(shù)里或使用屬性初始化器賦值了,這個錯誤也就會消失。
* 始終開啟strictPropertyInitialization
5. 記得指定函數(shù)的返回類型
TypeScript使你可以高度依賴類型推斷,這意味著只要在TS能推斷類型的地方,你就不需要標注類型。
然而這就像一把雙刃劍,一方面,它非常方便,并且減少了使用TypeScript的麻煩。而另一方面,有時推斷的類型可能會和你的預(yù)期不一致,從而降低了使用靜態(tài)類型提供的保障。
在下方的例子中,我們沒有注明返回類型,而是讓TypeScript來推斷函數(shù)的返回值。
- interface Person {
- name: string;
- age: number;
- }
- function getName(person: Person | undefined) {
- if (person && person.name) {
- return person.name;
- } else if (!person) {
- return "no name";
- }
- }
乍看之下,我們可能認為我們的方法很安全,并且始終返回的是string類型,然而,當我們明確聲明該函數(shù)的(預(yù)期)返回類型時就會發(fā)現(xiàn)報了一個錯。
- // COMPILE ERROR!
- // Function lacks ending return statement and return type does not include 'undefined'.
- function getName(person: Person | undefined): string
- {
- // ...
- }
順便說一句,這個錯誤只有當你開啟了strictNullChecks才會被檢測出來。
上述錯誤表明getName函數(shù)的返回值沒有覆蓋到一種情況:當person不為空,但是person.name為空的情況。這種情況所有if條件都不等于true,所以會返回undefined。
因此,TypeScript推斷此函數(shù)的返回類型為string | underfined,而我們聲明的卻是string。(譯者注:所以主動聲明函數(shù)返回值類型有助于幫我們提前捕捉一些不易察覺的bug)
* 始終標注函數(shù)的返回值類型
6. 不要將隱式類型變量存儲到對象中
TypeScript的類型檢查有時很微妙。
通常,當類型A至少具有和類型B相同的屬性,那么TypeScript就允許將類型A的對象賦值給類型B的變量。這意味著它可以包含其他屬性。
- // 譯者舉例:
- type A = {
- name: string;
- age: number;
- };
- type B = {
- name: string;
- };
- let a: A = {
- name: 'John',
- age: 12,
- };
- let b: B;
- // compile success
- b = a;
然而如果直接傳遞的是對象字面量,其行為是不同的。只有目標類型包含相同的屬性時,TypeScript才會允許它(傳遞)。此時不允許包含其他屬性。
- interface Person {
- name: string;
- }
- function getName(person: Person): string | undefined {
- // ...
- }
- // ok
- getName({ name: 'John' });
- // COMPILE ERROR
- // Argument of type '{ name: string; age: number; }' is not assignable to parameter of type 'Person'.
- getName({ name: 'John', age: 30 });
如果我們不是直接傳對象字面量,而是將對象存到常量里(再傳遞),這看起來沒有什么區(qū)別。然而這卻更改了類型檢查的行為:
- const person = { name: 'John', age: 30 };
- // OK
- getName(person);
傳遞額外的屬性可能會引起bug(例如當你想合并兩個對象時)。了解這個行為并且在可能的情況下,直接傳遞對象字面量。
* 請注意如何將對象傳遞給函數(shù)并且始終要考慮傳遞額外的屬性是否安全
7. 不要過度使用類型斷言
盡管TypeScript能對你的代碼進行很多推斷,但有時候你會比TypeScript更了解某個值的詳細信息。這時你可以通過類型斷言這種方式可以告訴編譯器,“相信我,我知道自己在干什么”。
比如說對一個從服務(wù)器請求回來的對象斷言,或者將一個子類型的對象斷言為父類型。
類型斷言需要保守使用。比如絕對不能在函數(shù)傳參類型不匹配時使用。
有一種更安全的使用類型斷言的方式:類型保護。類型保護是一個當返回true時能斷言其參數(shù)類型的函數(shù)。它可以提供代碼運行時的檢測,讓我們對傳入的變量是否符合預(yù)期這點上更有信心。
下面的代碼中,我們需要使用類型斷言,因為TypeScript不知道從后端返回的對象的類型。
- interface Person {
- name: string;
- age: number;
- }
- declare const fetchFromBackend: (url: string) => Promise<object>;
- declare const displayPerson: (person: Person) => void;
- fetchFromBackend('/person/1').then((person) => displayPerson(person as Person));
我們可以通過使用類型保護,提供一個簡單的運行時檢查來讓代碼更完善。我們假設(shè)一個對象只要擁有了name和age屬性那么它的類型就是Person。
- const isPerson = (obj: Object): obj is Person => 'name' in obj && 'age' in obj;
- fetchFromBackend('/person/1').then((person) => {
- if(isPerson(person)) {
- // Type of `person` is `Person` here!
- displayPerson(person);
- }
- })
你可以發(fā)現(xiàn),多虧了類型保護,在if語句中person的類型已經(jīng)可以被正確推斷了。
* 考慮使用類型保護來替代類型斷言
8. 不要對Partial類型使用擴展運算符
Partial是一個非常有用的類型,它的作用是將源類型的每個屬性都變成可選的。
Partial有個好的實際使用場景:當你有一個表示配置或選項的對象類型,并且想要創(chuàng)建一個該配置對象的子集來覆寫它。
你可能會寫出如下的代碼:
- interface Settings {
- a: string;
- b: number;
- }
- const defaultSettings: Settings = { /* ... */ };
- function getSettings(overrides: Partial<Settings>): Settings {
- return { ...defaultSettings, ...overrides };
- }
這看起來還不錯,但實際上揭示了TypeScript的類型系統(tǒng)中的一個漏洞。
看下方的代碼,result的類型是Settings,然而result.a的值卻是undefined了。
- const result = getSettings({ a: undefined, b: 2 });
由于擴展Partial是一種常見的模式,并且TypeScript的目標之一是在嚴格性和便利性之間取得平衡,所以可以說是TypeScript本身的設(shè)計帶來了這種不一致性。但是,意識到該問題仍然非常重要。
* 除非你確定對象里不包含顯式的undefined,否則不要對Parital對象使用擴展運算符
9. 不要過于相信Record類型
這是TypeScript內(nèi)置類型定義中的一個微妙情況的另一個示例。
Record定義了一個對象類型,其中所有key具有相同的類型,所有value具有相同的類型。 這非常適合表示值的映射和字典。
換句話說,Record<KeyType, ValueType> 等價于 { [key: KeyType]: ValueType }。
從下方代碼你可以看出,通過訪問record對象的屬性返回的值的類型應(yīng)該和ValueType保持一致。然而你會發(fā)現(xiàn)這不是完全正確的,因為abc的值會是undefined。
- const languages: Record<string, string> = {
- 'c++': 'static',
- 'java': 'static',
- 'python': 'dynamic',
- };
- const abc: string = languages['abc']; // undefined
這又是一個TypeScript選擇了便利性而不是嚴格性的例子。雖然大多數(shù)例子中這樣使用都是可以的,但是你仍然要小心些。
最簡單的修復(fù)方式就是使Record的第二個參數(shù)可選:
- const languages: Partial<Record<string, string>> = {
- 'c++': 'static',
- 'java': 'static',
- 'python': 'dynamic',
- };
- const abc = languages['abc']; // abc is infer to string | underfined
* 除非你確保沒問題,否則可以始終保持Record的值類型參數(shù)(第二個參數(shù))可選
10. 不要允許出現(xiàn)不合格的類型聲明
在定義業(yè)務(wù)域?qū)ο蟮念愋蜁r,通常會遇到類似以下的情況:
- interface Customer {
- acquisitionDate: Date;
- type: CustomerType;
- firstName?: string;
- lastName?: string;
- socialSecurityNumber?: string;
- companyName?: string;
- companyTaxId?: number;
- }
這個對象包含很多可選的對象。其中一些對象是當Customer表示人時(type === CustomerType.Individual)才有意義且必填,另外的則是當Custormer表示公司時(type === CustomerType.Institution)必填。
問題在于Customer類型不能反映這一點! 換句話說,它允許屬性的某些非法組合(例如,lastName和companyName都未定義)
這確實是有問題的。 你要么執(zhí)行額外的檢查,要么使用類型斷言來消除基于type屬性值的某些字段的可選性。
幸運的是,有一個更好的解決方案——辨析聯(lián)合類型。辨析聯(lián)合類型是在聯(lián)合類型的基礎(chǔ)上增加了一個功能:在運行時可以區(qū)分不同的方案。
我們將Customer類型重寫為兩種類型:Individual和Institution的聯(lián)合,各自包含一些特定的字段,并且有一個共有字段:type,它的值是一個字符串。此字段允許運行時檢查,并且TypeScript知道可以專門處理它。
- interface Individual {
- kind: 'individual';
- firstName: string;
- lastName: string;
- socialSecurityNumber: number;
- }
- interface Institution {
- kind: 'institutional';
- companyName: string;
- companyTaxId: number;
- }
- type Customer = Individual | Institution;
辨析聯(lián)合類型真正酷的地方是TypeScript提供了內(nèi)置的類型保護,可以讓你避免類型斷言。
- function getCustomerName(customer: Customer) {
- if (customer.kind === 'individual') {
- // The type of `customer` id `Individual`
- return customer.lastName;
- } else {
- // The type of `customer` id `Institution`
- return customer.companyName;
- }
- }
* 當遇到復(fù)雜的業(yè)務(wù)對象時盡量考慮使用辨析聯(lián)合類型。這可以幫你創(chuàng)建更貼合現(xiàn)實場景的類型
文章到此結(jié)束了!我希望這個列表可以像幫助我一樣,幫助你捕獲許多討厭的bug。
接下來是這篇文章所有建議的總結(jié):
- 考慮對所有外部提供的對象執(zhí)行運行時檢查(例如從后端獲取的對象,JSON反序列化的對象等)
- 不使要用any類型并開啟noImplicitAny
- 始終開啟strictNullChecks
- 始終開啟strictPropertyInitialization
- 始終標注函數(shù)的返回值類型
- 請注意如何將對象傳遞給函數(shù)并且始終要考慮傳遞額外的屬性是否安全
- 考慮使用類型保護來替代類型斷言
- 除非你確定對象里不包含顯式的undefined,否則不要對Parital對象使用擴展運算符
- 除非你確保沒問題,否則可以始終保持Record的值類型參數(shù)(第二
- 當遇到復(fù)雜的業(yè)務(wù)對象時盡量考慮使用辨析聯(lián)合類型。