如何編寫健壯的 TypeScript 庫(kù)?
當(dāng)你用 TypeScript 編寫庫(kù)時(shí),你通常不知道這個(gè)庫(kù)最終將如何被使用。即使你 警告潛在用戶,你編寫這個(gè)庫(kù)只是針對(duì) TypeScript 用戶,你還是可能會(huì)在某個(gè)時(shí)刻擁有 JavaScript 用戶——或者是因?yàn)樗麄儾活櫮愕木娑褂眠@個(gè)庫(kù),或者是他們因?yàn)閭鬟f性依賴而使用這個(gè)庫(kù)。這有一個(gè)非常重要的后果:你必須將這個(gè)庫(kù)設(shè)計(jì)成任何語(yǔ)言的開發(fā)者都可以使用!
其主要部分是函數(shù)定義和函數(shù)體。如果你針對(duì)一個(gè)純 TypeScript 讀者編寫,那么你只需定義函數(shù)類型并信任編譯器處理其它事情。如果你針對(duì)一個(gè)純 JavaScrpit 讀者編寫,那么你需要記錄那些類型,但在函數(shù)中將實(shí)際的類型設(shè)為unknown并檢查調(diào)用方傳遞的內(nèi)容。
例如,給定如下代碼
- interface Person {
- age: number;
- name?: string;
- }
- function describe(person: Person): string {
- let name = person.name ?? 'someone';
- return `${name} is ${person.age} years old!`;
- }
一個(gè) JS 用戶可能用任何東西來(lái)調(diào)用describe函數(shù)。
正確寫法:
- describe({ name: "chris" })
災(zāi)難性的錯(cuò)誤寫法:
- describe("potato");
最常見的 JS 錯(cuò)誤:
- describe(undefined);
你的庫(kù)的 JS 用戶并不是故意這么做的。恰恰相反,在任何足夠大的系統(tǒng)中,很容易將錯(cuò)誤的參數(shù)傳遞給系統(tǒng)中的某個(gè)函數(shù)。這通常是一個(gè)很難避免的錯(cuò)誤,比如在一個(gè)點(diǎn)上做了修改,許多其它地方需要更新,但漏掉了一個(gè)點(diǎn)。故意的 JS 開發(fā)者會(huì)把壞數(shù)據(jù)發(fā)送到你設(shè)計(jì)精美的 TS API 中。
如果你針對(duì)一個(gè)純 TypeScript 讀者編寫,那么你只需定義函數(shù)類型并信任編譯器處理其它事情
我故意不提 TypeScript 編譯非常嚴(yán)格,從一個(gè)與 JavaScript 沒有區(qū)別的級(jí)別到幾乎任何人可能想到的嚴(yán)格級(jí)別。這意味著,即使是 TypeScript 調(diào)用者也應(yīng)該像 JavaScript 調(diào)用者一樣被對(duì)待:眾所周知,他們到處亂扔any,忽略了事實(shí)上可能是null或undefined的地方。返回上面的示例代碼:
- interface Person {
- age: number;
- name?: string;
- }
- function describe(person: Person): string {
- let name = person.name ?? 'someone';
- return `${name} is ${person.age} years old!`;
- }
在沒有啟用嚴(yán)格標(biāo)識(shí)的情況下,TypeScript 用戶可以如下調(diào)用describe:
- function cueTheSobbing(data: any) {
- describe(data);
- }
- cueTheSobbing({ breakfastOf: ["eggs", "waffles"] });
或者這樣:
- describe(null);
或者這樣:
- describe({ age: null })
也就是說(shuō):JS 調(diào)用者大部分會(huì)出錯(cuò)的方式,TS 調(diào)用者在關(guān)閉嚴(yán)格性設(shè)置的情況下也會(huì)出錯(cuò)。這意味著故意的 TypeScript 用戶也會(huì)用壞數(shù)據(jù)調(diào)用你的庫(kù)。而且由于他們依賴其它庫(kù),這很可能不是他們的錯(cuò)誤,因?yàn)檫@種問(wèn)題可能發(fā)生在依賴圖中的任何地方。
因此,如果問(wèn)題是我們不能信任數(shù)據(jù),那么我們應(yīng)該怎么做?一個(gè)選項(xiàng)是使函數(shù)的所有參數(shù)實(shí)際為unknown,并用 JSDoc 指定它該如何。然而,那樣會(huì)使我們失去大量 TS 提供的能力。當(dāng)與函數(shù)交互時(shí),我們即使在內(nèi)部也不會(huì)得到補(bǔ)全或類型錯(cuò)誤,更不用說(shuō)我們的庫(kù)的用戶。但是正如我們剛剛看到的,我們也不能依賴類型定義來(lái)提供函數(shù)內(nèi)部的安全性。不過(guò),我們可以將這幾種方法結(jié)合起來(lái):指定類型定義,并將傳入的數(shù)據(jù)視為實(shí)際上的unknown。這確實(shí)帶來(lái)了運(yùn)行時(shí)開銷——我們稍后將圍繞這個(gè)權(quán)衡進(jìn)行詳細(xì)討論?,F(xiàn)在,我們可以先看看如何檢查類型。
首先,我們會(huì)像實(shí)際上會(huì)從調(diào)用者得到真正未知的數(shù)據(jù)來(lái)編寫我們的代碼,因?yàn)槲覀円呀?jīng)確定了這正是我們可能得到的。一旦我們完成了對(duì)unknown數(shù)據(jù)的校驗(yàn),我們就能夠?qū)⑺鎿Q為Person,而且所有東西都應(yīng)該繼續(xù)工作,但是現(xiàn)在我們可以保證它對(duì)任何拋給它的數(shù)據(jù)都能夠工作。
- function describe(person: unknown): string {
- let name = person.name ?? 'someone';
- return `${name} is ${person.age} years old`;
- }
這里有類型錯(cuò)誤,因?yàn)檫@里的person類型可能是undefined或"potato"或者任何其它類型。我們可以使用 TypeScript 的類型縮小的概念來(lái)保證安全。然而,從unknown縮小到特定的對(duì)象類型有點(diǎn)兒奇怪,因?yàn)槿绻愫?jiǎn)單地檢查是否typeof somethingUnknown === 'object',這會(huì)將類型縮小到{},這意味著它不會(huì)包含任何我們可能需要的類型。我們會(huì)先定義一個(gè)isObject輔助函數(shù),它會(huì)為我們提供正確的語(yǔ)義:
- function isObject(
- maybeObj: unknown
- ): maybeObj is Record<string | number | symbol, unknown> {
- return typeof maybeObj === 'object' && maybeObj !== null;
- }
我們還需要一種方法來(lái)檢查這個(gè)對(duì)象有沒有指定的屬性。如果in運(yùn)算符能以這種方式工作就太好了,但不幸的是,它沒有這樣工作。我們也可以內(nèi)聯(lián)這樣做,但是每次都需要類型轉(zhuǎn)換。我們可以稱之為has,類似于Object.hasOwnProperty方法。由于這還需要檢查isObject返回的類型集——在 JS 中索引一個(gè)對(duì)象的所有合法類型——我們這里會(huì)將其提取到一個(gè)新的Key類型。
這個(gè)has輔助函數(shù)的返回類型告訴類型系統(tǒng),如果主體為 true,傳入的項(xiàng)目有其原始類型而且它包含我們要檢查的屬性。
- type Key = string | number | symbol;
- function has<K extends Key, T>(
- key: K,
- t: T
- ): t is T & Record<K, unknown> {
- return key in t;
- }
現(xiàn)在我們可以將它們組合成一個(gè)類型保護(hù)器,來(lái)檢查給定對(duì)象是否是一個(gè) person:
- function isPerson(value: unknown): value is Person {
- return (
- isObject(value) &&
- has('age', value) && typeof value.age === 'number' &&
- (has('name', value) ? typeof value.name === 'string' : true)
- )
- }
現(xiàn)在,我們可以將所有這些集合到我們函數(shù)頂部的一個(gè)簡(jiǎn)單的檢查中,如果它不合法的話拋出一個(gè)有用的錯(cuò)誤。
- function describe(person: unknown): string {
- if (!isPerson(person)) {
- throw new Error('`describe` requires you to pass a `Person`');
- }
- let name = person.name ?? 'someone';
- return `${name} is ${person.age} years old`;
- }
既然我們已經(jīng)有了這個(gè)功能,我們可以將這里的person類型更新為Person來(lái)讓 TypeScript 用戶有更好的體驗(yàn)。
- function describe(person: Person): string {
- if (!isPerson(person)) {
- throw new Error(
- `'describe' takes a 'Person', but you passed ${JSON.stringify(person)}`
- );
- }
- let name = person.name ?? 'someone';
- return `${name} is ${person.age} years old`;
- }
TypeScript 支持在條件不包含斷言函數(shù)時(shí)拋出的這種模式泛化,這非常有用。我們可以編寫如下格式:
- function assert(
- predicate: unknown,
- message: string
- ): asserts predicate {
- if (!pred) {
- throw new Error(message);
- }
- }
現(xiàn)在我們的函數(shù)變得更簡(jiǎn)單:
- function describe(person: Person): string {
- assert(
- isPerson(person),
- `'describe' takes a 'Person', but you passed ${JSON.stringify(person)}`
- );
- let name = person.name ?? 'someone';
- return `${name} is ${person.age} years old`;
- }
到目前為止,一直都還不錯(cuò)!我們現(xiàn)在保證,無(wú)論誰(shuí)調(diào)用describe,無(wú)論是從 JS,還是從松散類型的 TS,或是從其它完全不同的語(yǔ)言,它都會(huì)做“正確”的事情,在出錯(cuò)時(shí)向調(diào)用者提供一個(gè)可操作的錯(cuò)誤。然而,根據(jù)我們的限制,這種運(yùn)行時(shí)校驗(yàn)會(huì)開銷過(guò)大而不可行。在一個(gè)瀏覽器中,我們通過(guò)網(wǎng)絡(luò)發(fā)送的額外代碼積累起來(lái):需要下載更多東西,也需要解析更多東西,這都會(huì)減慢我們的 app。在任何環(huán)境中,每次與describe函數(shù)交互時(shí)都會(huì)進(jìn)行額外的運(yùn)行時(shí)檢查。一種選項(xiàng)是利用一些編譯智能來(lái)在開發(fā)期間而不是在生產(chǎn)構(gòu)建中提供這些檢查。Babel 允許你將給定函數(shù)轉(zhuǎn)變成 noops,使得它們不完全沒有開銷,但開銷非常小。例如,Ember CLI 提供了一個(gè) Babel 插件將 Ember 的assert函數(shù)(其類型與我在上面定義的assert幾乎等同)轉(zhuǎn)變成 no-ops。你可以將它與任何可以消除無(wú)用代碼的工具結(jié)合起來(lái),以刪除所有沒有用到的輔助函數(shù)!
這種方案的缺點(diǎn)是,生產(chǎn)環(huán)境的錯(cuò)誤的錯(cuò)誤消息會(huì)比較糟糕,并且更難以調(diào)試。優(yōu)點(diǎn)是,在生產(chǎn)環(huán)境中,你將上傳更少的代碼且運(yùn)行時(shí)開銷更少。為了使依賴這種assert片段的代碼工作,終端用戶需要將它與任何具有良好的端到端測(cè)試覆蓋的功能、UI 組件等相結(jié)合。但是不管怎樣,這都是正確的:類型和測(cè)試消除了不同類型的 bugs,最好結(jié)合使用!