了解 TypeScript 泛型
已發表: 2022-03-10在本文中,我們將學習 TypeScript 中泛型的概念,並研究如何使用泛型來編寫模塊化、解耦和可重用的代碼。 在此過程中,我們將簡要討論它們如何適應更好的測試模式、錯誤處理方法和域/數據訪問分離。
一個真實的例子
我不想通過解釋它們是什麼來進入泛型的世界,而是通過提供一個直觀的例子來說明它們為什麼有用。 假設您的任務是構建一個功能豐富的動態列表。 您可以將其稱為數組、 ArrayList
、 List
、 std::vector
或其他名稱,具體取決於您的語言背景。 也許這個數據結構也必須有內置或可交換的緩衝區系統(如循環緩衝區插入選項)。 它將是普通 JavaScript 數組的包裝器,以便我們可以使用我們的結構而不是普通數組。
您將遇到的直接問題是類型系統施加的約束。 在這一點上,你不能以一種干淨利落的方式接受你想要的任何類型到函數或方法中(我們稍後會重新討論這個語句)。
唯一明顯的解決方案是為所有不同類型複制我們的數據結構:
const intList = IntegerList.create(); intList.add(4); const stringList = StringList.create(); stringList.add('hello'); const userList = UserList.create(); userList.add(new User('Jamie'));
這裡的.create()
語法可能看起來很隨意,實際上, new SomethingList()
會更直接,但稍後您會看到我們為什麼使用這個靜態工廠方法。 在內部, create
方法調用構造函數。
這真糟糕。 我們在這個集合結構中有很多邏輯,我們公然複製它來支持不同的用例,在這個過程中完全打破了 DRY 原則。 當我們決定更改我們的實現時,我們將不得不在我們支持的所有結構和類型(包括用戶定義的類型)中手動傳播/反映這些更改,如上面的後一個示例所示。 假設集合結構本身有 100 行長——維護多個不同的實現,而它們之間的唯一區別是類型,這將是一場噩夢。
如果您願意,可能會想到一個直接的解決方案,尤其是如果您有 OOP 思維方式,那就是考慮一個根“超類型”。 例如,在 C# 中,有一個名為object
的類型,而object
是System.Object
類的別名。 在 C# 的類型系統中,所有類型,無論是預定義的還是用戶定義的,無論是引用類型還是值類型,都直接或間接地從System.Object
繼承。 這意味著任何值都可以分配給對object
類型的變量(無需進入堆棧/堆和裝箱/拆箱語義)。
在這種情況下,我們的問題似乎解決了。 我們可以只使用any
這樣的類型,這將允許我們在集合中存儲我們想要的任何東西,而不必復制結構,事實上,這是非常正確的:
const intList = AnyList.create(); intList.add(4); const stringList = AnyList.create(); stringList.add('hello'); const userList = AnyList.create(); userList.add(new User('Jamie'));
讓我們看看使用any
的列表的實際實現:
class AnyList { private values: any[] = []; private constructor (values: any[]) { this.values = values; // Some more construction work. } public add(value: any): void { this.values.push(value); } public where(predicate: (value: any) => boolean): AnyList { return AnyList.from(this.values.filter(predicate)); } public select(selector: (value: any) => any): AnyList { return AnyList.from(this.values.map(selector)); } public toArray(): any[] { return this.values; } public static from(values: any[]): AnyList { // Perhaps we perform some logic here. // ... return new AnyList(values); } public static create(values?: any[]): AnyList { return new AnyList(values ?? []); } // Other collection functions. // ... }
所有方法都比較簡單,但我們將從構造函數開始。 它的可見性是私有的,因為我們假設我們的列表很複雜並且我們希望禁止任意構造。 我們也可能希望在構造之前執行邏輯,因此出於這些原因,並且為了保持構造函數的純淨,我們將這些問題委託給靜態工廠/輔助方法,這被認為是一種很好的做法。
提供了from
和create
的靜態方法。 from
方法接受一個值數組,執行自定義邏輯,然後使用它們來構造列表。 如果我們想用初始數據為我們的列表播種, create
靜態方法採用一個可選的值數組。 “空值合併運算符”( ??
)用於在未提供空數組的情況下使用空數組構造列表。 如果操作數的左側是null
或undefined
,我們將退回到右側,因為在這種情況下, values
是可選的,因此可能是undefined
。 您可以在相關的 TypeScript 文檔頁面了解更多關於空值合併的信息。
我還添加了一個select
和一個where
方法。 這些方法只是分別包裝了 JavaScript 的map
和filter
。 select
允許我們根據提供的選擇器函數將元素數組投影到新形式中,並且where
允許我們根據提供的謂詞函數過濾掉某些元素。 toArray
方法通過返回我們內部保存的數組引用簡單地將列表轉換為數組。
最後,假設User
類包含一個getName
方法,該方法返回一個名稱並接受一個名稱作為其第一個也是唯一的構造函數參數。
注意:一些讀者會從 C# 的 LINQ 中識別Where
和Select
,但請記住,我試圖保持簡單,因此我不擔心懶惰或延遲執行。 這些是可以而且應該在現實生活中進行的優化。
此外,作為一個有趣的註釋,我想討論“謂詞”的含義。 在離散數學和命題邏輯中,我們有“命題”的概念。 命題是一些可以被認為是真或假的陳述,例如“四可被二整除”。 “謂詞”是包含一個或多個變量的命題,因此命題的真實性取決於這些變量的真實性。 你可以把它想像成一個函數,比如P(x) = x is divisible by two
,因為我們需要知道x
的值來判斷語句的真假。 您可以在此處了解有關謂詞邏輯的更多信息。
使用any
會出現一些問題。 TypeScript 編譯器對列表/內部數組中的元素一無所知,因此它不會在where
或select
或添加元素時提供任何幫助:
// Providing seed data. const userList = AnyList.create([new User('Jamie')]); // This is fine and expected. userList.add(new User('Tom')); userList.add(new User('Alice')); // This is an acceptable input to the TS Compiler, // but it's not what we want. We'll definitely // be surprised later to find strings in a list // of users. userList.add('Hello, World!'); // Also acceptable. We have a large tuple // at this point rather than a homogeneous array. userList.add(0); // This compiles just fine despite the spelling mistake (extra 's'): // The type of `users` is any. const users = userList.where(user => user.getNames() === 'Jamie'); // Property `ssn` doesn't even exist on a `user`, yet it compiles. users.toArray()[0].ssn = '000000000'; // `usersWithId` is, again, just `any`. const usersWithId = userList.select(user => ({ id: newUuid(), name: user.getName() })); // Oops, it's "id" not "ID", but TS doesn't help us. // We compile just fine. console.log(usersWithId.toArray()[0].ID);
由於 TypeScript 只知道所有數組元素的類型是any
,它在編譯時無法幫助我們處理不存在的屬性或甚至不存在的getNames
函數,因此這段代碼會導致多個意外的運行時錯誤.
老實說,事情開始看起來相當慘淡。 我們嘗試為我們希望支持的每種具體類型實現我們的數據結構,但我們很快意識到這無論如何都無法維護。 然後,我們認為通過使用any
可以到達某個地方,這類似於依賴於所有類型派生自的繼承鏈中的根超類型,但我們得出結論,使用該方法我們失去了類型安全性。 那有什麼解決辦法?
事實證明,在文章的開頭,我撒了謊(有點):
“在這一點上,你不能以一種干淨利落的方式接受你想要的任何類型到函數或方法中。”
你實際上可以,這就是泛型的用武之地。注意我說的是“此時”,因為我假設我們在文章中的那個時候不知道泛型。
我將首先展示我們使用泛型的 List 結構的完整實現,然後我們將退後一步,討論它們實際上是什麼,並更正式地確定它們的語法。 我將其命名為TypedList
以區別於我們之前的AnyList
:
class TypedList<T> { private values: T[] = []; private constructor (values: T[]) { this.values = values; } public add(value: T): void { this.values.push(value); } public where(predicate: (value: T) => boolean): TypedList<T> { return TypedList.from<T>(this.values.filter(predicate)); } public select<U>(selector: (value: T) => U): TypedList<U> { return TypedList.from<U>(this.values.map(selector)); } public toArray(): T[] { return this.values; } public static from<U>(values: U[]): TypedList<U> { // Perhaps we perform some logic here. // ... return new TypedList<U>(values); } public static create<U>(values?: U[]): TypedList<U> { return new TypedList<U>(values ?? []); } // Other collection functions. // .. }
讓我們再次嘗試犯與之前相同的錯誤:
// Here's the magic. `TypedList` will operate on objects // of type `User` due to the `<User>` syntax. const userList = TypedList.create<User>([new User('Jamie')]); // The compiler expects this. userList.add(new User('Tom')); userList.add(new User('Alice')); // Argument of type '0' is not assignable to parameter // of type 'User'. ts(2345) userList.add(0); // Property 'getNames' does not exist on type 'User'. // Did you mean 'getName'? ts(2551) // Note: TypeScript infers the type of `users` to be // `TypedList<User>` const users = userList.where(user => user.getNames() === 'Jamie'); // Property 'ssn' does not exist on type 'User'. ts(2339) users.toArray()[0].ssn = '000000000'; // TypeScript infers `usersWithId` to be of type // `TypedList<`{ id: string, name: string }> const usersWithId = userList.select(user => ({ id: newUuid(), name: user.getName() })); // Property 'ID' does not exist on type '{ id: string; name: string; }'. // Did you mean 'id'? ts(2551) console.log(usersWithId.toArray()[0].ID)
如您所見,TypeScript 編譯器正在積極地幫助我們實現類型安全。 所有這些註釋都是我在嘗試編譯此代碼時從編譯器收到的錯誤。 泛型允許我們指定我們希望允許我們的列表對其進行操作的類型,並且從那裡,TypeScript 可以告訴一切的類型,一直到數組中各個對象的屬性。
我們提供的類型可以像我們希望的那樣簡單或複雜。 在這裡,您可以看到我們可以傳遞原語和復雜接口。 我們還可以傳遞其他數組、類或任何東西:
const numberList = TypedList.create<number>(); numberList.add(4); const stringList = TypedList.create<string>(); stringList.add('Hello, World'); // Example of a complex type interface IAircraft { apuStatus: ApuStatus; inboardOneRPM: number; altimeter: number; tcasAlert: boolean; pushBackAndStart(): Promise<void>; ilsCaptureGlidescope(): boolean; getFuelStats(): IFuelStats; getTCASHistory(): ITCASHistory; } const aircraftList = TypedList.create<IAircraft>(); aircraftList.add(/* ... */); // Aggregate and generate report: const stats = aircraftList.select(a => ({ ...a.getFuelStats(), ...a.getTCASHistory() }));
TypedList<T>
實現中T
和U
以及<T>
和<U>
的特殊用途是泛型的實際應用示例。 在完成了構建類型安全集合結構的指令之後,我們暫時將這個示例拋在腦後,一旦我們了解泛型實際上是什麼、它們如何工作以及它們的語法,我們將回到它。 當我學習一個新概念時,我總是喜歡先看一個正在使用的概念的複雜示例,這樣當我開始學習基礎知識時,我可以將基本主題與我現有的示例聯繫起來頭。
什麼是泛型?
理解泛型的一種簡單方法是將它們視為相對類似於佔位符或變量,但用於類型。 這並不是說您可以對泛型類型占位符執行與變量相同的操作,而是可以將泛型類型變量視為表示將來使用的具體類型的佔位符。 也就是說,使用泛型是一種根據稍後指定的類型編寫程序的方法。 這很有用的原因是因為它允許我們構建可在它們操作的不同類型(或與類型無關)上重用的數據結構。
這並不是最好的解釋,所以用更簡單的術語來說,正如我們所見,在編程中我們可能需要構建一個函數/類/數據結構來操作某種類型是很常見的,但是同樣常見的是,這樣的數據結構也需要在各種不同的類型上工作。 如果我們在設計數據結構時(在編譯時)陷入必須靜態聲明數據結構將在其上運行的具體類型的位置,我們很快就會發現我們需要重建那些正如我們在上面的示例中所見,我們希望支持的每種類型都以幾乎完全相同的方式構建結構。
泛型通過允許我們推遲對具體類型的需求直到它真正被知道來幫助我們解決這個問題。
TypeScript 中的泛型
對於泛型為什麼有用,我們現在有了一些基本的想法,並且我們已經在實踐中看到了一個稍微複雜的例子。 對於大多數人來說, TypedList<T>
實現可能已經很有意義了,特別是如果你來自靜態類型語言背景,但我記得當我第一次學習時很難理解這個概念,因此我想從簡單的函數開始構建該示例。 眾所周知,與軟件中的抽象相關的概念很難內化,所以如果泛型的概念還沒有完全流行,那完全沒問題,希望在本文結束時,這個想法至少會有點直觀。
為了能夠理解該示例,讓我們從簡單的函數開始。 我們將從“身份函數”開始,這是大多數文章(包括 TypeScript 文檔本身)都喜歡使用的。
在數學中,“恆等函數”是一個將其輸入直接映射到其輸出的函數,例如f(x) = x
。 你投入什麼,你就會得到什麼。 在 JavaScript 中,我們可以將其表示為:
function identity(input) { return input; }
或者,更簡潔:
const identity = input => input;
嘗試將其移植到 TypeScript 會帶來我們之前看到的相同類型系統問題。 解決方案是使用any
鍵入,我們知道這很少是一個好主意,為每種類型複制/重載函數(破壞 DRY),或使用泛型。
使用後一種選項,我們可以將函數表示如下:
// ES5 Function function identity<T>(input: T): T { return input; } // Arrow Function const identity = <T>(input: T): T => input; console.log(identity<number>(5)); // 5 console.log(identity<string>('hello')); // hello
此處的<T>
語法將此函數聲明為 Generic。 就像函數允許我們將任意輸入參數傳遞到其參數列表中一樣,使用通用函數,我們也可以傳遞任意類型參數。
identity<T>(input: T): T
和<T>(input: T): T
的簽名的<T>
部分在這兩種情況下都聲明所討論的函數將接受一個名為T
的泛型類型參數。 就像變量可以是任何名稱一樣,我們的通用佔位符也可以,但使用大寫字母“T”(“T”表示“類型”)並根據需要向下移動字母表是一種慣例。 請記住, T
是一種類型,因此我們還聲明我們將接受一個類型為T
的名稱input
的函數參數,並且我們的函數將返回類型T
。 這就是簽名的全部內容。 嘗試讓T = string
在你的腦海中 - 用這些簽名中的string
替換所有T
。 看到所有神奇的事情都沒有發生嗎? 看看它與您每天使用函數的非泛型方式有多相似?
請記住您對 TypeScript 和函數簽名的了解。 我們所說的只是T
是用戶在調用函數時將提供的任意類型,就像input
是用戶在調用函數時將提供的任意值一樣。 在這種情況下, input
必須是將來調用函數時T
的任何類型。
接下來,在“未來”中,在兩個日誌語句中,我們“傳入”我們希望使用的具體類型,就像我們做一個變量一樣。 注意這裡的措辭開關——在<T> signature
的初始形式中,當聲明我們的函數時,它是泛型的——也就是說,它適用於泛型類型,或者稍後指定的類型。 那是因為當我們實際編寫函數時,我們不知道調用者希望使用什麼類型。 但是,當調用者調用該函數時,他/她確切地知道他們想要使用什麼類型,在這種情況下是string
和number
。
您可以想像在第三方庫中以這種方式聲明日誌函數的想法——庫作者不知道使用該庫的開發人員想要使用什麼類型,因此他們使函數通用,本質上推遲了需求對於具體類型,直到它們真正為人所知。
我想強調的是,您應該以類似於將變量傳遞給函數的概念來考慮這個過程,以便獲得更直觀的理解。 我們現在所做的只是傳遞一個類型。
在我們使用number
參數調用函數時,出於所有意圖和目的,原始簽名可以被認為是identity(input: number): number
。 而且,在我們使用string
參數調用函數時,原始簽名也可能是identity(input: string): string
。 您可以想像,在進行調用時,每個泛型T
都會被您當時提供的具體類型替換。
探索通用語法
在 ES5 函數、箭頭函數、類型別名、接口和類的上下文中指定泛型有不同的語法和語義。 我們將在本節中探討這些差異。
探索通用語法——函數
到目前為止,您已經看到了一些泛型函數的示例,但重要的是要注意泛型函數可以接受多個泛型類型參數,就像它可以接受變量一樣。 您可以選擇要求一種、兩種、三種或多種類型,全部用逗號分隔(同樣,就像輸入參數一樣)。
此函數接受三種輸入類型並隨機返回其中一種:
function randomValue<T, U, V>( one: T, two: U, three: V ): T | U | V { // This is a tuple if you're not familiar. const options: [T, U, V] = [ one, two, three ]; const rndNum = getRndNumInInclusiveRange(0, 2); return options[rndNum]; } // Calling the function. // `value` has type `string | number | IAircraft` const value = randomValue< string, number, IAircraft >( myString, myNumber, myAircraft );
您還可以看到,根據我們使用的是 ES5 函數還是箭頭函數,語法略有不同,但兩者都在簽名中聲明了類型參數:
const randomValue = <T, U, V>( one: T, two: U, three: V ): T | U | V => { // This is a tuple if you're not familiar. const options: [T, U, V] = [ one, two, three ]; const rndNum = getRndNumInInclusiveRange(0, 2); return options[rndNum]; }
請記住,類型沒有“唯一性約束”——您可以傳入任何您希望的組合,例如兩個string
和一個number
。 此外,就像輸入參數在函數體的“範圍內”一樣,泛型類型參數也是如此。 前一個示例表明我們可以從函數體內完全訪問T
、 U
和V
,並且我們使用它們來聲明一個本地 3 元組。
您可以想像這些泛型在某個“上下文”或某個“生命週期”內運行,這取決於它們的聲明位置。 函數的泛型在函數簽名和主體(以及由嵌套函數創建的閉包)的範圍內,而在類或接口或類型別名上聲明的泛型在類或接口或類型別名的所有成員的範圍內。
函數泛型的概念不僅限於“自由函數”或“浮動函數”(未附加到對像或類的函數,一個 C++ 術語),它們也可以用於附加到其他結構的函數。
我們可以將該randomValue
放在一個類中,我們可以這樣稱呼它:
class Utils { public randomValue<T, U, V>( one: T, two: U, three: V ): T | U | V { // ... } // Or, as an arrow function: public randomValue = <T, U, V>( one: T, two: U, three: V ): T | U | V => { // ... } }
我們還可以在接口中放置一個定義:
interface IUtils { randomValue<T, U, V>( one: T, two: U, three: V ): T | U | V; }
或者在類型別名中:
type Utils = { randomValue<T, U, V>( one: T, two: U, three: V ): T | U | V; }
就像以前一樣,這些泛型類型參數在特定函數的“範圍內”——它們不是類、接口或類型別名範圍的。 它們只存在於指定它們的特定功能中。 要在結構的所有成員之間共享泛型類型,您必須註釋結構的名稱本身,我們將在下面看到。
探索通用語法——類型別名
對於類型別名,通用語法用於別名的名稱。
例如,一些接受值的“動作”函數可能會改變該值,但返回 void 可以寫成:
type Action<T> = (val: T) => void;
注意:了解 Action<T> 委託的 C# 開發人員應該熟悉這一點。
或者,可以這樣聲明一個同時接受錯誤和值的回調函數:
type CallbackFunction<T> = (err: Error, data: T) => void; const usersApi = { get(uri: string, cb: CallbackFunction<User>) { /// ... } }
憑藉我們對函數泛型的了解,我們可以更進一步,使 API 對像上的函數也泛型:
type CallbackFunction<T> = (err: Error, data: T) => void; const api = { // `T` is available for use within this function. get<T>(uri: string, cb: CallbackFunction<T>) { /// ... } }
現在,我們說get
函數接受一些泛型類型參數,不管是什麼, CallbackFunction
都會接收它。 我們基本上已經“傳遞”了進入get
的T
作為CallbackFunction
的T
如果我們更改名稱,也許這會更有意義:
type CallbackFunction<TData> = (err: Error, data: TData) => void; const api = { get<TResponse>(uri: string, cb: CallbackFunction<TResponse>) { // ... } }
使用T
前綴類型參數只是一種約定,就像使用I
前綴接口或使用_
成員變量一樣。 您可以在此處看到CallbackFunction
接受某種類型 ( TData
),它表示函數可用的數據負載,而get
接受表示 HTTP 響應數據類型/形狀 ( TResponse
) 的類型參數。 HTTP 客戶端 ( api
) 與 Axios 類似,使用TResponse
的任何內容作為CallbackFunction
的TData
。 這允許 API 調用者選擇他們將從 API 接收的數據類型(假設管道中的其他地方我們有將 JSON 解析為 DTO 的中間件)。
如果我們想更進一步,我們可以修改CallbackFunction
上的泛型類型參數以接受自定義錯誤類型:
type CallbackFunction<TData, TError> = (err: TError, data: TData) => void;
而且,就像您可以將函數參數設為可選一樣,您也可以使用類型參數。 如果用戶沒有提供錯誤類型,我們將默認將其設置為錯誤構造函數:
type CallbackFunction<TData, TError = Error> = (err: TError, data: TData) => void;
有了這個,我們現在可以通過多種方式指定回調函數類型:
const apiOne = { // `Error` is used by default for `CallbackFunction`. get<TResponse>(uri: string, cb: CallbackFunction<TResponse>) { // ... } }; apiOne.get<string>('uri', (err: Error, data: string) => { // ... }); const apiTwo = { // Override the default and use `HttpError` instead. get<TResponse>(uri: string, cb: CallbackFunction<TResponse, HttpError>) { // ... } }; apiTwo.get<string>('uri', (err: HttpError, data: string) => { // ... });
這種默認參數的想法在函數、類、接口等中是可以接受的——它不僅限於類型別名。 在到目前為止我們看到的所有示例中,我們可以將任何我們想要的類型參數分配給默認值。 類型別名,就像函數一樣,可以使用任意數量的泛型類型參數。
探索通用語法——接口
如您所見,可以將泛型類型參數提供給接口上的函數:
interface IUselessFunctions { // Not generic printHelloWorld(); // Generic identity<T>(t: T): T; }
在這種情況下, T
只存在於作為其輸入和返回類型的identity
等函數。
我們還可以通過指定接口本身接受泛型來使接口的所有成員都可以使用類型參數,就像類和類型別名一樣。 我們稍後會在討論更複雜的泛型用例時討論存儲庫模式,所以如果您從未聽說過它也沒關係。 存儲庫模式允許我們抽像出我們的數據存儲,以使業務邏輯與持久性無關。 如果您希望創建一個對未知實體類型進行操作的通用存儲庫接口,我們可以將其鍵入如下:
interface IRepository<T> { add(entity: T): Promise<void>; findById(id: string): Promise<T>; updateById(id: string, updated: T): Promise<void>; removeById(id: string): Promise<void>; }
注意:關於存儲庫有很多不同的想法,從 Martin Fowler 的定義到 DDD 聚合定義。 我只是試圖展示泛型的用例,所以我不太關心在實現方面是否完全正確。 不使用通用存儲庫肯定有話要說,但我們稍後會討論。
正如您在此處看到的, IRepository
是一個接口,其中包含用於存儲和檢索數據的方法。 它對一些名為T
的泛型類型參數進行操作,並將T
作為add
和updateById
的輸入,以及findById
的 promise 解析結果。
請記住,在接口名稱上接受泛型類型參數與允許每個函數本身接受泛型類型參數之間存在很大差異。 正如我們在這裡所做的那樣,前者確保接口中的每個函數都在相同的類型T
上運行。 也就是說,對於IRepository<User>
,接口中使用T
的每個方法現在都在處理User
對象。 使用後一種方法,每個函數都可以使用它想要的任何類型。 例如,只能將User
添加到 Repository 但能夠接收回Policies
或Orders
將是非常特殊的,如果我們無法強制類型為在所有方法中統一。
給定的接口不僅可以包含共享類型,還可以包含其成員獨有的類型。 例如,如果我們想模擬一個數組,我們可以輸入這樣的接口:
interface IArray<T> { forEach(func: (elem: T, index: number) => void): this; map<U>(func: (elem: T, index: number) => U): IArray<U>; }
在這種情況下, forEach
和map
都可以從接口名稱訪問T
如前所述,您可以想像T
在接口的所有成員的範圍內。 儘管如此,沒有什麼能阻止其中的各個函數也接受它們自己的類型參數。 map
函數可以使用U
。 現在, map
可以訪問T
和U
。 我們必須將參數命名為不同的字母,例如U
,因為T
已經被採用,我們不希望命名衝突。 就像它的名字一樣, map
會將數組中T
類型的元素“映射”到U
類型的新元素。 它將T
s 映射到U
s。 這個函數的返回值是接口本身,現在在新類型U
上運行,這樣我們就可以在某種程度上模仿 JavaScript 流暢的數組鍊式語法。
當我們實現存儲庫模式並討論依賴注入時,我們將很快看到泛型和接口的強大示例。 再一次,我們可以接受盡可能多的通用參數,也可以選擇一個或多個堆疊在接口末尾的默認參數。
探索通用語法——類
就像我們可以將泛型類型參數傳遞給類型別名、函數或接口一樣,我們也可以將一個或多個傳遞給類。 這樣做後,該類的所有成員以及擴展的基類或實現的接口都可以訪問該類型參數。
讓我們再構建一個集合類,但比上面的TypedList
簡單一點,這樣我們就可以看到泛型類型、接口和成員之間的互操作。 稍後我們將看到一個將類型傳遞給基類和接口繼承的示例。
除了map
和forEach
方法之外,我們的集合將僅支持基本的 CRUD 功能。
class Collection<T> { private elements: T[] = []; constructor (elements: T[] = []) { this.elements = elements; } add(elem: T): void { this.elements.push(elem); } contains(elem: T): boolean { return this.elements.includes(elem); } remove(elem: T): void { this.elements = this.elements.filter(existing => existing !== elem); } forEach(func: (elem: T, index: number) => void): void { return this.elements.forEach(func); } map<U>(func: (elem: T, index: number) => U): Collection<U> { return new Collection<U>(this.elements.map(func)); } } const stringCollection = new Collection<string>(); stringCollection.add('Hello, World!'); const numberCollection = new Collection<number>(); numberCollection.add(3.14159); const aircraftCollection = new Collection<IAircraft>(); aircraftCollection.add(myAircraft);
讓我們討論一下這裡發生了什麼。 Collection
類接受一個名為T
的泛型類型參數。 類的所有成員都可以訪問該類型。 我們用它來定義一個類型為T[]
的私有數組,我們也可以用Array<T>
的形式表示它(參見?泛型再次用於正常的 TS 數組類型)。 此外,大多數成員函數以某種方式利用該T
,例如通過控制添加和刪除的類型或檢查集合是否包含元素。
最後,正如我們之前看到的, map
方法需要它自己的泛型類型參數。 We need to define in the signature of map
that some type T
is mapped to some type U
through a callback function, thus we need a U
. That U
is unique to that function in particular, which means we could have another function in that class that also accepts some type named U
, and that'd be fine, because those types are only “in scope” for their functions and not shared across them, thus there are no naming collisions. What we can't do is have another function that accepts a generic parameter named T
, for that'd conflict with the T
from the class signature.
You can see that when we call the constructor, we pass in the type we want to work with (that is, what type each element of the internal array will be). In the calling code at the bottom of the example, we work with string
s, number
s, and IAircraft
s.
How could we make this work with an interface? What if we have different collection interfaces that we might want to swap out or inject into calling code? To get that level of reduced coupling (low coupling and high cohesion is what we should always aim for), we'll need to depend on an abstraction. Generally, that abstraction will be an interface, but it could also be an abstract class.
Our collection interface will need to be generic, so let's define it:
interface ICollection<T> { add(t: T): void; contains(t: T): boolean; remove(t: T): void; forEach(func: (elem: T, index: number) => void): void; map<U>(func: (elem: T, index: number) => U): ICollection<U>; }
Now, let's suppose we have different kinds of collections. We could have an in-memory collection, one that stores data on disk, one that uses a database, and so on. By having an interface, the dependent code can depend upon the abstraction, permitting us to swap out different implementations without affecting the existing code. Here is the in-memory collection.
class InMemoryCollection<T> implements ICollection<T> { private elements: T[] = []; constructor (elements: T[] = []) { this.elements = elements; } add(elem: T): void { this.elements.push(elem); } contains(elem: T): boolean { return this.elements.includes(elem); } remove(elem: T): void { this.elements = this.elements.filter(existing => existing !== elem); } forEach(func: (elem: T, index: number) => void): void { return this.elements.forEach(func); } map<U>(func: (elem: T, index: number) => U): ICollection<U> { return new InMemoryCollection<U>(this.elements.map(func)); } }
The interface describes the public-facing methods and properties that our class is required to implement, expecting you to pass in a concrete type that those methods will operate upon. However, at the time of defining the class, we still don't know what type the API caller will wish to use. Thus, we make the class generic too — that is, InMemoryCollection
expects to receive some generic type T
, and whatever it is, it's immediately passed to the interface, and the interface methods are implemented using that type.

Calling code can now depend on the interface:
// Using type annotation to be explicit for the purposes of the // tutorial. const userCollection: ICollection<User> = new InMemoryCollection<User>(); function manageUsers(userCollection: ICollection<User>) { userCollection.add(new User()); }
With this, any kind of collection can be passed into the manageUsers
function as long as it satisfies the interface. This is useful for testing scenarios — rather than dealing with over-the-top mocking libraries, in unit and integration test scenarios, I can replace my SqlServerCollection<T>
(for example) with InMemoryCollection<T>
instead and perform state-based assertions instead of interaction-based assertions. This setup makes my tests agnostic to implementation details, which means they are, in turn, less likely to break when refactoring the SUT.
At this point, we should have worked up to the point where we can understand that first TypedList<T>
example. 這裡又是:
class TypedList<T> { private values: T[] = []; private constructor (values: T[]) { this.values = values; } public add(value: T): void { this.values.push(value); } public where(predicate: (value: T) => boolean): TypedList<T> { return TypedList.from<T>(this.values.filter(predicate)); } public select<U>(selector: (value: T) => U): TypedList<U> { return TypedList.from<U>(this.values.map(selector)); } public toArray(): T[] { return this.values; } public static from<U>(values: U[]): TypedList<U> { // Perhaps we perform some logic here. // ... return new TypedList<U>(values); } public static create<U>(values?: U[]): TypedList<U> { return new TypedList<U>(values ?? []); } // Other collection functions. // .. }
The class itself accepts a generic type parameter named T
, and all members of the class are provided access to it. The instance method select
and the two static methods from
and create
, which are factories, accept their own generic type parameter named U
.
The create
static method permits the construction of a list with optional seed data. It accepts some type named U
to be the type of every element in the list as well as an optional array of U
elements, typed as U[]
. When it calls the list's constructor with new
, it passes that type U
as the generic parameter to TypedList
. This creates a new list where the type of every element is U
. It is exactly the same as how we could call the constructor of our collection class earlier with new Collection<SomeType>()
. The only difference is that the generic type is now passing through the create
method rather than being provided and used at the top-level.
I want to make sure this is really, really clear. I've stated a few times that we can think about passing around types in a similar way that we do variables. It should already be quite intuitive that we can pass a variable through as many layers of indirection as we please. Forget generics and types for a moment and think about an example of the form:
class MyClass { private constructor (t: number) {} public static create(u: number) { return new MyClass(u); } } const myClass = MyClass.create(2.17);
This is very similar to what is happening with the more-involved example, the difference being that we're working on generic type parameters, not variables. Here, 2.17
becomes the u
in create
, which ultimately becomes the t
in the private constructor.
In the case of generics:
class MyClass<T> { private constructor () {} public static create<U>() { return new MyClass<U>(); } } const myClass = MyClass.create<number>();
The U
passed to create
is ultimately passed in as the T
for MyClass<T>
. When calling create
, we provided number
as U
, thus now U = number
. We put that U
(which, again, is just number
) into the T
for MyClass<T>
, so that MyClass<T>
effectively becomes MyClass<number>
. The benefit of generics is that we're opened up to be able to work with types in this abstract and high-level fashion, similar to how we can normal variables.
The from
method constructs a new list that operates on an array of elements of type U
. It uses that type U
, just like create
, to construct a new instance of the TypedList
class, now passing in that type U
for T
.
The where
instance method performs a filtering operation based upon a predicate function. There's no mapping happening, thus the types of all elements remain the same throughout. The filter
method available on JavaScript's array returns a new array of values, which we pass into the from
method. So, to be clear, after we filter out the values that don't satisfy the predicate function, we get an array back containing the elements that do. All those elements are still of type T
, which is the original type that the caller passed to create
when the list was first created. Those filtered elements get given to the from
method, which in turn creates a new list containing all those values, still using that original type T
. The reason why we return a new instance of the TypedList
class is to be able to chain new method calls onto the return result. This adds an element of “immutability” to our list.
Hopefully, this all provides you with a more intuitive example of generics in practice, and their reason for existence. Next, we'll look at a few of the more advanced topics.
Generic Type Inference
Throughout this article, in all cases where we've used generics, we've explicitly defined the type we're operating on. It's important to note that in most cases, we do not have to explicitly define the type parameter we pass in, for TypeScript can infer the type based on usage.
If I have some function that returns a random number, and I pass the return result of that function to identity
from earlier without specifying the type parameter, it will be inferred automatically as number
:
// `value` is inferred as type `number`. const value = identity(getRandomNumber());
為了演示類型推斷,我之前從TypedList
結構中刪除了所有技術上無關的類型註釋,您可以從下面的圖片中看到,TSC 仍然正確推斷所有類型:
沒有多餘類型聲明TypedList
:
class TypedList<T> { private values: T[] = []; private constructor (values: T[]) { this.values = values; } public add(value: T) { this.values.push(value); } public where(predicate: (value: T) => boolean) { return TypedList.from(this.values.filter(predicate)); } public select<U>(selector: (value: T) => U) { return TypedList.from(this.values.map(selector)); } public toArray() { return this.values; } public static from<U>(values: U[]) { // Perhaps we perform some logic here. // ... return new TypedList(values); } public static create<U>(values?: U[]) { return new TypedList(values ?? []); } // Other collection functions. // .. }
基於函數返回值和傳入from
和構造函數的輸入類型,TSC 了解所有類型信息。 在下圖中,我將多個圖像拼接在一起,顯示了 Visual Studio 的 Code TypeScript 的語言擴展(以及編譯器)推斷所有類型:

通用約束
有時,我們想對泛型類型施加約束。 也許我們不能支持所有存在的類型,但我們可以支持其中的一個子集。 假設我們要構建一個返回某個集合長度的函數。 如上所示,我們可以有許多不同類型的數組/集合,從默認的 JavaScript Array
到我們的自定義數組。 我們如何讓我們的函數知道某個泛型類型附加了一個length
屬性? 同樣,如何將我們傳遞給函數的具體類型限制為那些包含我們需要的數據的類型? 例如,像這樣的一個例子是行不通的:
function getLength<T>(collection: T): number { // Error. TS does not know that a type T contains a `length` property. return collection.length; }
答案是利用通用約束。 我們可以定義一個接口來描述我們需要的屬性:
interface IHasLength { length: number; }
現在,在定義我們的泛型函數時,我們可以將泛型類型限制為擴展該接口的類型:
function getLength<T extends IHasLength>(collection: T): number { // Restricting `collection` to be a type that contains // everything within the `IHasLength` interface. return collection.length; }
真實世界的例子
在接下來的幾節中,我們將討論一些真實世界的泛型示例,它們可以創建更優雅、更易於推理的代碼。 我們已經看到了很多瑣碎的例子,但我想討論一些錯誤處理、數據訪問模式和前端 React 狀態/道具的方法。
現實世界的例子——錯誤處理的方法
與大多數編程語言一樣,JavaScript 包含一流的錯誤處理機制—— try
/ catch
。 儘管如此,我並不是很喜歡它在使用時的外觀。 這並不是說我不使用該機制,我確實使用了,但我傾向於盡可能地隱藏它。 通過抽象try
/ catch
away,我還可以在可能失敗的操作中重用錯誤處理邏輯。
假設我們正在構建一些數據訪問層。 這是應用程序的一層,它包裝了用於處理數據存儲方法的持久性邏輯。 如果我們正在執行數據庫操作,並且如果跨網絡使用該數據庫,則可能會發生特定於 DB 的錯誤和暫時異常。 擁有專用數據訪問層的部分原因是將數據庫從業務邏輯中抽像出來。 因此,我們不能將此類特定於 DB 的錯誤拋出堆棧並從這一層拋出。 我們需要先包裝它們。
讓我們看一個使用try
/ catch
的典型實現:
async function queryUser(userID: string): Promise<User> { try { const dbUser = await db.raw(` SELECT * FROM users WHERE user_id = ? `, [userID]); return mapper.toDomain(dbUser); } catch (e) { switch (true) { case e instanceof DbErrorOne: return Promise.reject(new WrapperErrorOne()); case e instanceof DbErrorTwo: return Promise.reject(new WrapperErrorTwo()); case e instanceof NetworkError: return Promise.reject(new TransientException()); default: return Promise.reject(new UnknownError()); } } }
切換true
只是一種能夠將 switch case
語句用於我的錯誤檢查邏輯的方法,而不是必須聲明 if/else if 鏈——這是我第一次從@Jeffijoe 那裡聽到的技巧。
如果我們有多個這樣的函數,我們必須複製這個錯誤包裝邏輯,這是一個非常糟糕的做法。 對於一個功能來說,它看起來相當不錯,但對於許多人來說,這將是一場噩夢。 為了抽像出這個邏輯,我們可以將它包裝在一個自定義的錯誤處理函數中,該函數將傳遞結果,但捕獲並包裝任何拋出的錯誤:
async function withErrorHandling<T>( dalOperation: () => Promise<T> ): Promise<T> { try { // This unwraps the promise and returns the type `T`. return await dalOperation(); } catch (e) { switch (true) { case e instanceof DbErrorOne: return Promise.reject(new WrapperErrorOne()); case e instanceof DbErrorTwo: return Promise.reject(new WrapperErrorTwo()); case e instanceof NetworkError: return Promise.reject(new TransientException()); default: return Promise.reject(new UnknownError()); } } }
為了確保這樣做有意義,我們有一個名為withErrorHandling
的函數,它接受一些泛型類型參數T
。 這個T
表示我們期望從dalOperation
回調函數返回的 promise 的成功解析值的類型。 通常,由於我們只是返回 async dalOperation
函數的返回結果,因此我們不需要await
它,因為這會將函數包裝在第二個無關的 Promise 中,我們可以將await
留給調用代碼。 在這種情況下,我們需要捕獲任何錯誤,因此需要await
。
我們現在可以使用這個函數來包裝我們之前的 DAL 操作:
async function queryUser(userID: string) { return withErrorHandling<User>(async () => { const dbUser = await db.raw(` SELECT * FROM users WHERE user_id = ? `, [userID]); return mapper.toDomain(dbUser); }); }
我們開始了。 我們有一個類型安全和錯誤安全的功能用戶查詢功能。
此外,如前所述,如果 TypeScript 編譯器有足夠的信息來隱式推斷類型,則不必顯式傳遞它們。 在這種情況下,TSC 知道函數的返回結果就是泛型類型。 因此,如果mapper.toDomain(user)
返回了User
類型,則根本不需要傳入該類型:
async function queryUser(userID: string) { return withErrorHandling(async () => { const dbUser = await db.raw(` SELECT * FROM users WHERE user_id = ? `, [userID]); return mapper.toDomain(user); }); }
我傾向於喜歡的另一種錯誤處理方法是 Monadic Types。 Either Monad 是Either<T, U>
形式的代數數據類型,其中T
可以表示錯誤類型, U
可以表示失敗類型。 使用 Monadic Types 聽從函數式編程,一個主要的好處是錯誤變得類型安全——正常的函數簽名不會告訴 API 調用者該函數可能拋出什麼錯誤。 假設我們從queryUser
內部拋出NotFound
錯誤。 queryUser(userID: string): Promise<User>
的簽名並沒有告訴我們任何相關信息。 但是,像queryUser(userID: string): Promise<Either<NotFound, User>>
這樣的簽名絕對可以。 我不會在本文中解釋像 Either Monad 這樣的 monad 是如何工作的,因為它們可能非常複雜,並且必須將它們視為 monadic 的多種方法,例如映射/綁定。 如果您想了解更多關於它們的信息,我會推薦 Scott Wlaschin 的兩次 NDC 演講,這里和這裡,以及 Daniel Chamber 的演講這裡。 該站點以及這些博客文章也可能很有用。
現實世界的例子——存儲庫模式
讓我們看一下泛型可能有用的另一個用例。 大多數後端系統都需要以某種方式與數據庫交互——這可能是像 PostgreSQL 這樣的關係數據庫,像 MongoDB 這樣的文檔數據庫,甚至可能是像 Neo4j 這樣的圖形數據庫。
由於作為開發人員,我們應該以低耦合和高內聚設計為目標,因此考慮遷移數據庫系統可能產生的後果將是一個公平的論據。 考慮到不同的數據訪問需求可能更喜歡不同的數據訪問方法也是公平的(這開始有點進入 CQRS,這是一種分離讀寫的模式。如果你願意,請參閱 Martin Fowler 的帖子和 MSDN 列表了解更多信息。Vaughn Vernon 的“實施領域驅動設計”和 Scott Millet 的“領域驅動設計的模式、原則和實踐”這兩本書也很不錯)。 我們還應該考慮自動化測試。 大多數解釋使用 Node.js 構建後端系統的教程都將數據訪問代碼與業務邏輯與路由混合在一起。 也就是說,他們傾向於將 MongoDB 與 Mongoose ODM 一起使用,採用 Active Record 方法,並且沒有清晰地分離關注點。 這種技術在大型應用程序中是不受歡迎的。 當您決定要將一個數據庫系統遷移到另一個數據庫系統時,或者當您意識到您更喜歡不同的數據訪問方法時,您必須刪除舊的數據訪問代碼,用新代碼替換它,並希望您在此過程中沒有向路由和業務邏輯引入任何錯誤。
當然,您可能會爭辯說單元測試和集成測試將防止回歸,但如果這些測試發現它們自己耦合併依賴於它們應該不知道的實現細節,它們也可能會在過程中中斷。
解決此問題的常用方法是存儲庫模式。 它說要調用代碼,我們應該允許我們的數據訪問層僅僅模仿對像或域實體的內存集合。 這樣,我們可以讓業務驅動設計而不是數據庫(數據模型)。 對於大型應用程序,稱為域驅動設計的架構模式變得有用。 在 Repository Pattern 中,Repositories 是組件,最常見的是類,它封裝並保存內部所有訪問數據源的邏輯。 有了這個,我們可以將數據訪問代碼集中到一層,使其易於測試和易於重用。 此外,我們可以在兩者之間放置一個映射層,允許我們將與數據庫無關的域模型映射到一系列一對一的表映射。 如果您願意,存儲庫上可用的每個功能都可以選擇使用不同的數據訪問方法。
存儲庫、工作單元、跨表的數據庫事務等有許多不同的方法和語義。 由於這是一篇關於泛型的文章,我不想過多地深入雜草,因此在這裡我將舉例說明一個簡單的示例,但重要的是要注意不同的應用程序有不同的需求。 例如,DDD 聚合的存儲庫與我們在這裡所做的完全不同。 我在這裡描述存儲庫實現的方式並不是我在實際項目中實現它們的方式,因為有很多缺失的功能和不理想的架構實踐在使用中。
假設我們將Users
和Tasks
作為域模型。 這些可能只是 POTO——普通的 TypeScript 對象。 它們中沒有數據庫的概念,因此,您不會像使用 Mongoose 那樣調用User.save()
。 使用存儲庫模式,我們可能會保留一個用戶或從我們的業務邏輯中刪除一個任務,如下所示:
// Querying the DB for a User by their ID. const user: User = await userRepository.findById(userID); // Deleting a Task by its ID. await taskRepository.deleteById(taskID); // Deleting a Task by its owner's ID. await taskRepository.deleteByUserId(userID);
很明顯,您可以看到所有混亂和瞬態數據訪問邏輯是如何隱藏在這個存儲庫外觀/抽象背後的,從而使業務邏輯與持久性問題無關。
讓我們從構建一些簡單的領域模型開始。 這些是應用程序代碼將與之交互的模型。 它們在這裡是貧乏的,但會保持自己的邏輯來滿足現實世界中的業務不變量,也就是說,它們不會僅僅是數據包。
interface IHasIdentity { id: string; } class User implements IHasIdentity { public constructor ( private readonly _id: string, private readonly _username: string ) {} public get id() { return this._id; } public get username() { return this._username; } } class Task implements IHasIdentity { public constructor ( private readonly _id: string, private readonly _title: string ) {} public get id() { return this._id; } public get title() { return this._title; } }
稍後您會看到為什麼我們將身份輸入信息提取到界面中。 這種定義域模型並通過構造函數傳遞所有內容的方法不是我在現實世界中的做法。 此外,依賴抽象域模型類比免費獲取id
實現的接口更可取。
對於存儲庫,因為在這種情況下,我們希望許多相同的持久性機制將在不同的域模型之間共享,我們可以將存儲庫方法抽象為通用接口:
interface IRepository<T> { add(entity: T): Promise<void>; findById(id: string): Promise<T>; updateById(id: string, updated: T): Promise<void>; deleteById(id: string): Promise<void>; existsById(id: string): Promise<boolean>; }
我們還可以更進一步,創建一個通用存儲庫來減少重複。 為簡潔起見,我不會在這裡這樣做,並且我應該注意,通用存儲庫接口,例如這個接口和通用存儲庫,一般來說,往往不被接受,因為你可能有某些實體是只讀的,或者寫-only,或無法刪除的,或類似的。 這取決於應用程序。 此外,我們沒有“工作單元”的概念來跨表共享事務,這是我將在現實世界中實現的功能,但是,由於這是一個小型演示,我沒有想變得太技術化。
讓我們從實現我們的UserRepository
開始。 我將定義一個IUserRepository
接口,該接口包含特定於用戶的方法,因此當我們依賴注入具體實現時,允許調用代碼依賴於該抽象:
interface IUserRepository extends IRepository<User> { existsByUsername(username: string): Promise<boolean>; } class UserRepository implements IUserRepository { // There are 6 methods to implement here all using the // concrete type of `User` - Five from IRepository<User> // and the one above. }
任務存儲庫將類似,但會包含應用程序認為合適的不同方法。
在這裡,我們定義了一個擴展通用接口的接口,因此我們必須傳遞我們正在處理的具體類型。 正如您從兩個界面中看到的那樣,我們的概念是發送這些 POTO 域模型,然後將它們取出。 調用代碼不知道底層的持久性機制是什麼,這就是重點。
下一個需要考慮的是,根據我們選擇的數據訪問方法,我們必須處理特定於數據庫的錯誤。 例如,我們可以將 Mongoose 或 Knex Query Builder 放在這個存儲庫後面,在這種情況下,我們將不得不處理那些特定的錯誤——我們不希望它們冒泡到業務邏輯中,因為這會破壞關注點分離並引入更大程度的耦合。
讓我們為我們希望使用的可以為我們處理錯誤的數據訪問方法定義一個 Base Repository:
class BaseKnexRepository { // A constructor. /** * Wraps a likely to fail database operation within a function that handles errors by catching * them and wrapping them in a domain-safe error. * * @param dalOp The operation to perform upon the database. */ public async withErrorHandling<T>(dalOp: () => Promise<T>) { try { return await dalOp(); } catch (e) { // Use a proper logger: console.error(e); // Handle errors properly here. } } }
現在,我們可以在存儲庫中擴展這個基類並訪問該通用方法:
interface IUserRepository extends IRepository<User> { existsByUsername(username: string): Promise<boolean>; } class UserRepository extends BaseKnexRepository implements IUserRepository { private readonly dbContext: Knex | Knex.Transaction; public constructor (private knexInstance: Knex | Knex.Transaction) { super(); this.dbContext = knexInstance; } // Example `findById` implementation: public async findById(id: string): Promise<User> { return this.withErrorHandling<User>(async () => { const dbUser = await this.dbContext<DbUser>() .select() .where({ user_id: id }) .first(); // Maps type DbUser to User return mapper.toDomain(dbUser); }); } // There are 5 methods to implement here all using the // concrete type of `User`. }
請注意,我們的函數從數據庫中檢索DbUser
並將其映射到User
域模型,然後再返回它。 這是數據映射器模式,它對於保持關注點分離至關重要。 DbUser
是到數據庫表的一對一映射——它是存儲庫操作的數據模型——因此高度依賴於所使用的數據存儲技術。 出於這個原因, DbUser
永遠不會離開存儲庫,並且會在返回之前映射到User
域模型。 我沒有展示DbUser
實現,但它可能只是一個簡單的類或接口。
到目前為止,使用由泛型提供支持的存儲庫模式,我們已經設法將數據訪問問題抽象為小單元,並保持類型安全和可重用性。
最後,出於單元和集成測試的目的,假設我們將保留一個內存存儲庫實現,以便在測試環境中,我們可以注入該存儲庫,並在磁盤上執行基於狀態的斷言,而不是使用模擬框架。 這種方法迫使一切都依賴於面向公眾的接口,而不是允許測試與實現細節耦合。 由於每個存儲庫之間的唯一區別是它們選擇在ISomethingRepository
接口下添加的方法,我們可以構建一個通用的內存存儲庫並在特定類型的實現中擴展它:
class InMemoryRepository<T extends IHasIdentity> implements IRepository<T> { protected entities: T[] = []; public findById(id: string): Promise<T> { const entityOrNone = this.entities.find(entity => entity.id === id); return entityOrNone ? Promise.resolve(entityOrNone) : Promise.reject(new NotFound()); } // Implement the rest of the IRepository<T> methods here. }
這個基類的目的是執行處理內存存儲的所有邏輯,這樣我們就不必在內存測試存儲庫中復制它。 由於findById
類的方法,此存儲庫必須了解實體包含一個id
字段,這就是為什麼IHasIdentity
接口上的通用約束是必要的。 我們之前看到過這個接口——這是我們的領域模型實現的。
有了這個,在構建內存用戶或任務存儲庫時,我們可以擴展這個類並自動實現大部分方法:
class InMemoryUserRepository extends InMemoryRepository<User> { public async existsByUsername(username: string): Promise<boolean> { const userOrNone = this.entities.find(entity => entity.username === username); return Boolean(userOrNone); // or, return !!userOrNone; } // And that's it here. InMemoryRepository implements the rest. }
在這裡,我們的InMemoryRepository
需要知道實體具有id
和username
等字段,因此我們將User
作為通用參數傳遞。 User
已經實現IHasIdentity
,因此滿足了通用約束,並且我們還聲明我們也有一個username
屬性。
現在,當我們希望從業務邏輯層使用這些存儲庫時,非常簡單:
class UserService { public constructor ( private readonly userRepository: IUserRepository, private readonly emailService: IEmailService ) {} public async createUser(dto: ICreateUserDTO) { // Validate the DTO: // ... // Create a User Domain Model from the DTO const user = userFactory(dto); // Persist the Entity await this.userRepository.add(user); // Send a welcome email await this.emailService.sendWelcomeEmail(user); } }
(請注意,在實際應用程序中,我們可能會將對emailService
的調用移至作業隊列,以免增加請求的延遲,並希望能夠在失敗時執行冪等重試(——並不是說電子郵件發送特別重要)首先是冪等的)。此外,將整個用戶對像傳遞給服務也是有問題的。另一個需要注意的問題是,我們可能會發現自己處於這樣一個位置,即在用戶被持久化之後但在電子郵件之前服務器崩潰有一些緩解模式可以防止這種情況發生,但出於實用主義的目的,人工干預和適當的日誌記錄可能會很好)。
我們開始了——使用具有泛型功能的存儲庫模式,我們已經將 DAL 與 BLL 完全分離,並設法以類型安全的方式與我們的存儲庫交互。 我們還開發了一種方法來快速構建同樣類型安全的內存存儲庫,用於單元和集成測試,允許真正的黑盒和實現無關測試。 如果沒有泛型類型,這一切都不可能實現。
作為免責聲明,我想再次指出,這個 Repository 實現缺乏很多。 我想保持示例簡單,因為重點是泛型的利用,這就是為什麼我沒有處理重複或擔心事務的原因。 體面的存儲庫實現將單獨使用一篇文章來完整和正確地解釋,並且實現細節會根據您是在做 N 層架構還是 DDD 而發生變化。 這意味著,如果您希望使用存儲庫模式,則不應將我的實現視為最佳實踐。
現實世界的例子——反應狀態和道具
React 功能組件的 state、ref 和其他 hooks 也是通用的。 如果我有一個包含Task
屬性的接口,並且我想在 React 組件中保存它們的集合,我可以這樣做:
import React, { useState } from 'react'; export const MyComponent: React.FC = () => { // An empty array of tasks as the initial state: const [tasks, setTasks] = useState<Task[]>([]); // A counter: // Notice, type of `number` is inferred automatically. const [counter, setCounter] = useState(0); return ( <div> <h3>Counter Value: {counter}</h3> <ul> { tasks.map(task => ( <li key={task.id}> <TaskItem {...task} /> </li> )) } </ul> </div> ); };
此外,如果我們想將一系列 props 傳遞給我們的函數,我們可以使用通用React.FC<T>
類型並訪問props
:
import React from 'react'; interface IProps { id: string; title: string; description: string; } export const TaskItem: React.FC<IProps> = (props) => { return ( <div> <h3>{props.title}</h3> <p>{props.description}</p> </div> ); };
TS 編譯器會自動將props
的類型推斷為IProps
。
結論
在本文中,我們看到了泛型及其用例的許多不同示例,從簡單的集合到錯誤處理方法,再到數據訪問層隔離等等。 用最簡單的術語來說,泛型允許我們構建數據結構,而無需知道它們在編譯時運行的具體時間。 希望這有助於更多地打開主題,使泛型的概念更直觀一點,並帶來它們的真正力量。