【譯】10個幫助你捕獲更多Bug的TypeScript建議

本文翻譯自Miłosz Piechocki提供的TypeScript迷你書typescriptmasterclass.com(須要發送郵件獲取)java

其我的博客codewithstyle.info也有許多關於TS的文章能夠學習。python

1. 對TypeScript提供運行時檢查的思考

有一個對TypeScript常見的誤解是:一個變量只要標註了類型,那麼它老是會檢查本身的數據類型是否與咱們的預期一致。c++

與該誤解相呼應的想法會認爲:對一個從後端返回的對象進行類型標註能夠在代碼運行時執行檢查來確保對象類型的正確性。git

然而這個想法是錯誤的!由於TypeScript最終是被編譯成JavaScript代碼,而且瀏覽器中運行的也是JavaScript。此時(譯者注:運行時)全部的類型信息都丟失了,因此TypeScript沒法自動驗證類型。github

理解這一點的一個好方法是查看編譯後的代碼: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();
	  })
}
複製代碼

能夠看到接口定義在編譯後已經徹底消失了,並且這裏也不會有任何驗證性的代碼。編程

不過你最好能夠本身去執行運行時校驗,許多庫(譯者注:io-ts)能幫你作到這點。不過,請記住,這必定會帶來性能開銷。json

*** 考慮對全部外部提供的對象執行運行時檢查(例如從後端獲取的對象,JSON反序列化的對象等)**後端

2. 不要將類型定義爲any

使用TypeScript時,能夠將變量或函數參數的類型聲明爲any,可是這樣作也意味着該變量脫離了類型安全保障。瀏覽器

不過聲明爲any類型也會有好處,在某種場景下頗有幫助(例如將類型逐步添加到現有的JavaScript代碼庫中,譯者注:通常是將代碼庫從js升級到ts時)。可是它也像一個逃生艙口,會大大下降代碼的類型安全性。

當類型安全涵蓋儘量多的代碼時,它是最有效的。不然,安全網中會存在漏洞,漏洞可能會經過漏洞傳播。例如:若是函數返回any,則使用其返回值的全部表達式類型也將變成any。

因此你應該儘可能避免使用any類型。幸運的是,TypeScript3.0引入了類型安全的替代方案——unknown。能夠將任何值賦給unknown類型的變量,可是不能將unknown類型的變量的值賦給任何變量(這點不一樣於any)。

若是你的函數返回的是unknown類型的值,則調用方須要執行檢查(使用類型保護),或至少將值顯式轉換爲某個特定類型。(譯者注:若是對這段不理解,能夠參考下這篇文章,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類型有時會有些麻煩,可是這也會讓代碼更易於理解,而且讓你在開發時更加註意。

另外,你須要開啓noImplicitAny,每當編譯器推斷某個值的類型爲any時就會拋出錯誤。換句話說,它讓你顯式的標註出全部會出現any的場景。

儘管最終目標仍是消除有any的狀況,但明確申明any仍然是有益的:例如在code review時能夠更容易捕獲他們。

*** 不要使用any類型並開啓noImplicitAny**

3. 開啓strictNullChecks

你已經見過多少次這樣的報錯信息了?

TypeError: undefined is not an object
複製代碼

我打賭有不少次了,JavaScript(甚至是軟件編程)中最多見的bug來源之一就是忘記處理空值。

在JavaScript中用null或undefined來表示空值。開發者們常常樂觀的認爲給定的變量不會是空的,因而就忘記處理空值的狀況。

function printName(person: Person) {
  console.log(person.name.toUpperCase());
}

// RUNTIME ERROR!  TypeError: undefined is not an object   
// (evaluating 'person.name') 
printName(undefined);
複製代碼

經過開啓strictNullChecks,編譯器會迫使你去作相關的檢查,這對防止出現這種常見問題起到了重要的做用。

默認狀況下,typescript的每一個類型都包含null和undefined這兩個值。也就是說,null和undefined能夠被賦值給任意類型的任何變量。

而開啓strictNullChecks會更改該行爲。因爲沒法將undefined做爲Person類型的參數傳遞,所以下方的代碼會在編譯時報錯。

// COMPILE ERROR! 
// Argument of type 'undefined' is not assignable to parameter of type 'Person'. printName(undefined); 
複製代碼

那若是你確實就想將undefined傳遞給printName怎麼辦?那你能夠調整類型簽名,可是仍然會要求你處理undefined的狀況。

function printName(person: Person | undefined) {
  // COMPILE ERROR!
  // Object is possibly 'undefined'. 
 	console.log(person.name.toUpperCase());
}
複製代碼

你能夠經過確保person是被定義的來修復這個錯誤:

function printName(person: Person | undefined) { 
	if (person) {
		console.log(person.name.toUpperCase());
	}
} 
複製代碼

不幸的是,strictNullChecks默認是不開啓的,咱們須要在tsconfig.json中進行配置。

另外,strictNullChecks是更通用的嚴格模式的一部分,能夠經過strict標誌啓用它。你絕對應該這樣作!由於編譯器的設置越嚴格,你就能夠儘早發現更多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沒有被初始化,所以在運行時調用sayHello就會報錯。

形成這個錯誤的根本緣由是這個屬性沒有在構造函數裏或使用屬性初始化器賦值,因此它(至少在最初)是undefined,所以他的類型就會變成string | undefined。

開啓strictPropertyInitialization會提示如下錯誤:

Property 'name' has no initializer and is not assigned in the constructor. 
複製代碼

固然,若是你在構造函數裏或使用屬性初始化器賦值了,這個錯誤也就會消失。

*** 始終開啓strictPropertyInitialization**

5. 記得指定函數的返回類型

TypeScript使你能夠高度依賴類型推斷,這意味着只要在TS能推斷類型的地方,你就不須要標註類型。

然而這就像一把雙刃劍,一方面,它很是方便,而且減小了使用TypeScript的麻煩。而另外一方面,有時推斷的類型可能會和你的預期不一致,從而下降了使用靜態類型提供的保障。

在下方的例子中,咱們沒有註明返回類型,而是讓TypeScript來推斷函數的返回值。

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類型,然而,當咱們明確聲明該函數的(預期)返回類型時就會發現報了一個錯。

// COMPILE ERROR! 
// Function lacks ending return statement and return type does not include 'undefined'. 
function getName(person: Person | undefined): string 
{
	// ... 
}
複製代碼

順便說一句,這個錯誤只有當你開啓了strictNullChecks纔會被檢測出來。

上述錯誤代表getName函數的返回值沒有覆蓋到一種狀況:當person不爲空,可是person.name爲空的狀況。這種狀況全部if條件都不等於true,因此會返回undefined。

所以,TypeScript推斷此函數的返回類型爲string | underfined,而咱們聲明的倒是string。(譯者注:因此主動聲明函數返回值類型有助於幫咱們提早捕捉一些不易察覺的bug)

*** 始終標註函數的返回值類型**

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 });
複製代碼

若是咱們不是直接傳對象字面量,而是將對象存到常量裏(再傳遞),這看起來沒有什麼區別。然而這卻更改了類型檢查的行爲:

const person = { name: 'John', age: 30 }; 
// OK 
getName(person); 
複製代碼

傳遞額外的屬性可能會引發bug(例如當你想合併兩個對象時)。瞭解這個行爲而且在可能的狀況下,直接傳遞對象字面量。

*** 請注意如何將對象傳遞給函數而且始終要考慮傳遞額外的屬性是否安全**

7. 不要過分使用類型斷言

儘管TypeScript能對你的代碼進行不少推斷,但有時候你會比TypeScript更瞭解某個值的詳細信息。這時你能夠經過類型斷言這種方式能夠告訴編譯器,「相信我,我知道本身在幹什麼」。

好比說對一個從服務器請求回來的對象斷言,或者將一個子類型的對象斷言爲父類型。

**類型斷言須要保守使用。**好比絕對不能在函數傳參類型不匹配時使用。

有一種更安全的使用類型斷言的方式:類型保護。類型保護是一個當返回true時能斷言其參數類型的函數。它能夠提供代碼運行時的檢測,讓咱們對傳入的變量是否符合預期這點上更有信心。

下面的代碼中,咱們須要使用類型斷言,由於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));
複製代碼

咱們能夠經過使用類型保護,提供一個簡單的運行時檢查來讓代碼更完善。咱們假設一個對象只要擁有了nameage屬性那麼它的類型就是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);
  }
})
複製代碼

你能夠發現,多虧了類型保護,在if語句中person的類型已經能夠被正確推斷了。

*** 考慮使用類型保護來替代類型斷言**

8. 不要對Partial類型使用擴展運算符

Partial是一個很是有用的類型,它的做用是將源類型的每一個屬性都變成可選的。

Partial有個好的實際使用場景:當你有一個表示配置或選項的對象類型,而且想要建立一個該配置對象的子集來覆寫它。

你可能會寫出以下的代碼:

interface Settings {
  a: string;
  b: number;
}

const defaultSettings: Settings = { /* ... */ }; 

function getSettings(overrides: Partial<Settings>): Settings {
  return { ...defaultSettings, ...overrides };
}
複製代碼

這看起來還不錯,但實際上揭示了TypeScript的類型系統中的一個漏洞。

看下方的代碼,result的類型是Settings,然而result.a的值倒是undefined了。

const result = getSettings({ a: undefined, b: 2 });
複製代碼

因爲擴展Partial是一種常見的模式,而且TypeScript的目標之一是在嚴格性和便利性之間取得平衡,因此能夠說是TypeScript自己的設計帶來了這種不一致性。可是,意識到該問題仍然很是重要。

*** 除非你肯定對象裏不包含顯式的undefined,不然不要對Parital對象使用擴展運算符**

9. 不要過於相信Record類型

這是TypeScript內置類型定義中的一個微妙狀況的另外一個示例。

Record定義了一個對象類型,其中全部key具備相同的類型,全部value具備相同的類型。 這很是適合表示值的映射和字典。

換句話說,Record<KeyType, ValueType> 等價於 { [key: KeyType]: ValueType }

從下方代碼你能夠看出,經過訪問record對象的屬性返回的值的類型應該和ValueType保持一致。然而你會發現這不是徹底正確的,由於abc的值會是undefined。

const languages: Record<string, string> = {
    'c++': 'static',
    'java': 'static',
    'python': 'dynamic',
};


const abc: string = languages['abc']; // undefined
複製代碼

這又是一個TypeScript選擇了便利性而不是嚴格性的例子。雖然大多數例子中這樣使用都是能夠的,可是你仍然要當心些。

最簡單的修復方式就是使Record的第二個參數可選:

const languages: Partial<Record<string, string>> = {
    'c++': 'static',
    'java': 'static',
    'python': 'dynamic',
};

const abc = languages['abc']; // abc is infer to string | underfined
複製代碼

*** 除非你確保沒問題,不然能夠始終保持Record的值類型參數(第二個參數)可選**

10. 不要容許出現不合格的類型聲明

在定義業務域對象的類型時,一般會遇到相似如下的狀況:

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都未定義)

這確實是有問題的。 你要麼執行額外的檢查,要麼使用類型斷言來消除基於type屬性值的某些字段的可選性。

幸運的是,有一個更好的解決方案——辨析聯合類型。辨析聯合類型是在聯合類型的基礎上增長了一個功能:在運行時能夠區分不一樣的方案。

咱們將Customer類型重寫爲兩種類型:IndividualInstitution的聯合,各自包含一些特定的字段,而且有一個共有字段:type,它的值是一個字符串。此字段容許運行時檢查,而且TypeScript知道能夠專門處理它。

interface Individual {
  kind: 'individual';
  firstName: string;
  lastName: string;
  socialSecurityNumber: number;
}

interface Institution {
  kind: 'institutional';
  companyName: string;
  companyTaxId: number;
}

type Customer = Individual | Institution;
複製代碼

辨析聯合類型真正酷的地方是TypeScript提供了內置的類型保護,可讓你避免類型斷言。

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;
  }
}
複製代碼

*** 當遇到複雜的業務對象時儘可能考慮使用辨析聯合類型。這能夠幫你建立更貼合現實場景的類型**

文章到此結束了!我但願這個列表能夠像幫助我同樣,幫助你捕獲許多討厭的bug。

接下來是這篇文章全部建議的總結:

  1. 考慮對全部外部提供的對象執行運行時檢查(例如從後端獲取的對象,JSON反序列化的對象等)

  2. 不使要用any類型並開啓noImplicitAny

  3. 始終開啓strictNullChecks

  4. 始終開啓strictPropertyInitialization

  5. 始終標註函數的返回值類型

  6. 請注意如何將對象傳遞給函數而且始終要考慮傳遞額外的屬性是否安全

  7. 考慮使用類型保護來替代類型斷言

  8. 除非你肯定對象裏不包含顯式的undefined,不然不要對Parital對象使用擴展運算符

  9. 除非你確保沒問題,不然能夠始終保持Record的值類型參數(第二個參數)可選

  10. 當遇到複雜的業務對象時儘可能考慮使用辨析聯合類型。

相關文章
相關標籤/搜索