創建了一個 “重學TypeScript” 的微信羣,想加羣的小夥伴,加我微信 "semlinker",備註重學TS。已出 TS 系列文章 40 篇。
覺得 TypeScript 泛型有點難,想系統學習 TypeScript 泛型相關知識的小夥伴們看過來,本文從八個方面入手,全方位帶你一步步學習 TypeScript 中泛型,詳細的內容大綱請看下圖:
一、泛型是什麼
軟件工程中,我們不僅要創建一致的定義良好的 API,同時也要考慮可重用性。組件不僅能夠支持當前的數據類型,同時也能支持未來的數據類型,這在創建大型系統時爲你提供了十分靈活的功能。
在像 C# 和 Java 這樣的語言中,可以使用泛型來創建可重用的組件,一個組件可以支持多種類型的數據。這樣用戶就可以以自己的數據類型來使用組件。
設計泛型的關鍵目的是在成員之間提供有意義的約束,這些成員可以是:類的實例成員、類的方法、函數參數和函數返回值。
爲了便於大家更好地理解上述的內容,我們來舉個例子,在這個例子中,我們將一步步揭示泛型的作用。首先我們來定義一個通用的 identity
函數,該函數接收一個參數並直接返回它:
function identity (value) {
return value;
}
console.log(identity(1)) // 1
現在,我們將 identity
函數做適當的調整,以支持 TypeScript 的 Number 類型的參數:
function identity (value: Number) : Number {
return value;
}
console.log(identity(1)) // 1
這裏 identity
的問題是我們將 Number
類型分配給參數和返回類型,使該函數僅可用於該原始類型。但該函數並不是可擴展或通用的,很明顯這並不是我們所希望的。
我們確實可以把 Number
換成 any
,我們失去了定義應該返回哪種類型的能力,並且在這個過程中使編譯器失去了類型保護的作用。我們的目標是讓 identity
函數可以適用於任何特定的類型,爲了實現這個目標,我們可以使用泛型來解決這個問題,具體實現方式如下:
function identity <T>(value: T) : T {
return value;
}
console.log(identity<Number>(1)) // 1
對於剛接觸 TypeScript 泛型的讀者來說,首次看到 <T>
語法會感到陌生。但這沒什麼可擔心的,就像傳遞參數一樣,我們傳遞了我們想要用於特定函數調用的類型。
參考上面的圖片,當我們調用 identity<Number>(1)
,Number
類型就像參數 1
一樣,它將在出現 T
的任何位置填充該類型。圖中 <T>
內部的 T
被稱爲類型變量,它是我們希望傳遞給 identity 函數的類型佔位符,同時它被分配給 value
參數用來代替它的類型:此時 T
充當的是類型,而不是特定的 Number 類型。
其中 T
代表 Type,在定義泛型時通常用作第一個類型變量名稱。但實際上 T
可以用任何有效名稱代替。除了 T
之外,以下是常見泛型變量代表的意思:
K(Key):表示對象中的鍵類型;
V(Value):表示對象中的值類型;
E(Element):表示元素類型。
其實並不是只能定義一個類型變量,我們可以引入希望定義的任何數量的類型變量。比如我們引入一個新的類型變量 U
,用於擴展我們定義的 identity
函數:
function identity <T, U>(value: T, message: U) : T {
console.log(message);
return value;
}
console.log(identity<Number, string>(68, "Semlinker"));
除了爲類型變量顯式設定值之外,一種更常見的做法是使編譯器自動選擇這些類型,從而使代碼更簡潔。我們可以完全省略尖括號,比如:
function identity <T, U>(value: T, message: U) : T {
console.log(message);
return value;
}
console.log(identity(68, "Semlinker"));
對於上述代碼,編譯器足夠聰明,能夠知道我們的參數類型,並將它們賦值給 T 和 U,而不需要開發人員顯式指定它們。下面我們來看張動圖,直觀地感受一下類型傳遞的過程:
(圖片來源:https://medium.com/better-programming/typescript-generics-90be93d8c292)
如你所見,該函數接收你傳遞給它的任何類型,使得我們可以爲不同類型創建可重用的組件。現在我們再來看一下 identity
函數:
function identity <T, U>(value: T, message: U) : T {
console.log(message);
return value;
}
相比之前定義的 identity
函數,新的 identity
函數增加了一個類型變量 U
,但該函數的返回類型我們仍然使用 T
。如果我們想要返回兩種類型的對象該怎麼辦呢?針對這個問題,我們有多種方案,其中一種就是使用元組,即爲元組設置通用的類型:
function identity <T, U>(value: T, message: U) : [T, U] {
return [value, message];
}
雖然使用元組解決了上述的問題,但有沒有其它更好的方案呢?答案是有的,你可以使用泛型接口。
二、泛型接口
爲了解決上面提到的問題,首先讓我們創建一個用於的 identity
函數通用 Identities
接口:
interface Identities<V, M> {
value: V,
message: M
}
在上述的 Identities
接口中,我們引入了類型變量 V
和 M
,來進一步說明有效的字母都可以用於表示類型變量,之後我們就可以將 Identities
接口作爲 identity
函數的返回類型:
function identity<T, U> (value: T, message: U): Identities<T, U> {
console.log(value + ": " + typeof (value));
console.log(message + ": " + typeof (message));
let identities: Identities<T, U> = {
value,
message
};
return identities;
}
console.log(identity(68, "Semlinker"));
以上代碼成功運行後,在控制檯會輸出以下結果:
68: number
Semlinker: string
{value: 68, message: "Semlinker"}
泛型除了可以應用在函數和接口之外,它也可以應用在類中,下面我們就來看一下在類中如何使用泛型。
三、泛型類
在類中使用泛型也很簡單,我們只需要在類名後面,使用 <T, ...>
的語法定義任意多個類型變量,具體示例如下:
interface GenericInterface<U> {
value: U
getIdentity: () => U
}
class IdentityClass<T> implements GenericInterface<T> {
value: T
constructor(value: T) {
this.value = value
}
getIdentity(): T {
return this.value
}
}
const myNumberClass = new IdentityClass<Number>(68);
console.log(myNumberClass.getIdentity()); // 68
const myStringClass = new IdentityClass<string>("Semlinker!");
console.log(myStringClass.getIdentity()); // Semlinker!
接下來我們以實例化 myNumberClass
爲例,來分析一下其調用過程:
在實例化
IdentityClass
對象時,我們傳入Number
類型和構造函數參數值68
;之後在
IdentityClass
類中,類型變量T
的值變成Number
類型;IdentityClass
類實現了GenericInterface<T>
,而此時T
表示Number
類型,因此等價於該類實現了GenericInterface<Number>
接口;而對於
GenericInterface<U>
接口來說,類型變量U
也變成了Number
。這裏我有意使用不同的變量名,以表明類型值沿鏈向上傳播,且與變量名無關。
泛型類可確保在整個類中一致地使用指定的數據類型。比如,你可能已經注意到在使用 Typescript 的 React 項目中使用了以下約定:
type Props = {
className?: string
...
};
type State = {
submitted?: bool
...
};
class MyComponent extends React.Component<Props, State> {
...
}
在以上代碼中,我們將泛型與 React 組件一起使用,以確保組件的 props 和 state 是類型安全的。
相信看到這裏一些讀者會有疑問,我們在什麼時候需要使用泛型呢?通常在決定是否使用泛型時,我們有以下兩個參考標準:
當你的函數、接口或類將處理多種數據類型時;
當函數、接口或類在多個地方使用該數據類型時。
很有可能你沒有辦法保證在項目早期就使用泛型的組件,但是隨着項目的發展,組件的功能通常會被擴展。這種增加的可擴展性最終很可能會滿足上述兩個條件,在這種情況下,引入泛型將比複製組件來滿足一系列數據類型更乾淨。
我們將在本文的後面探討更多滿足這兩個條件的用例。不過在這樣做之前,讓我們先介紹一下 Typescript 泛型提供的其他功能。
四、泛型約束
有時我們可能希望限制每個類型變量接受的類型數量,這就是泛型約束的作用。下面我們來舉幾個例子,介紹一下如何使用泛型約束。
4.1 確保屬性存在
有時候,我們希望類型變量對應的類型上存在某些屬性。這時,除非我們顯式地將特定屬性定義爲類型變量,否則編譯器不會知道它們的存在。
一個很好的例子是在處理字符串或數組時,我們會假設 length
屬性是可用的。讓我們再次使用 identity
函數並嘗試輸出參數的長度:
function identity<T>(arg: T): T {
console.log(arg.length); // Error
return arg;
}
在這種情況下,編譯器將不會知道 T
確實含有 length
屬性,尤其是在可以將任何類型賦給類型變量 T
的情況下。我們需要做的就是讓類型變量 extends
一個含有我們所需屬性的接口,比如這樣:
interface Length {
length: number;
}
function identity<T extends Length>(arg: T): T {
console.log(arg.length); // 可以獲取length屬性
return arg;
}
T extends Length
用於告訴編譯器,我們支持已經實現 Length
接口的任何類型。之後,當我們使用不含有 length
屬性的對象作爲參數調用 identity
函數時,TypeScript 會提示相關的錯誤信息:
identity(68); // Error
// Argument of type '68' is not assignable to parameter of type 'Length'.(2345)
此外,我們還可以使用 ,
號來分隔多種約束類型,比如:<T extends Length, Type2, Type3>
。而對於上述的 length
屬性問題來說,如果我們顯式地將變量設置爲數組類型,也可以解決該問題,具體方式如下:
function identity<T>(arg: T[]): T[] {
console.log(arg.length);
return arg;
}
// or
function identity<T>(arg: Array<T>): Array<T> {
console.log(arg.length);
return arg;
}
4.2 檢查對象上的鍵是否存在
泛型約束的另一個常見的使用場景就是檢查對象上的鍵是否存在。不過在看具體示例之前,我們得來了解一下 keyof
操作符,keyof
操作符是在 TypeScript 2.1 版本引入的,該操作符可以用於獲取某種類型的所有鍵,其返回類型是聯合類型。 "耳聽爲虛,眼見爲實",我們來舉個 keyof
的使用示例:
interface Person {
name: string;
age: number;
location: string;
}
type K1 = keyof Person; // "name" | "age" | "location"
type K2 = keyof Person[]; // number | "length" | "push" | "concat" | ...
type K3 = keyof { [x: string]: Person }; // string | number
通過 keyof
操作符,我們就可以獲取指定類型的所有鍵,之後我們就可以結合前面介紹的 extends
約束,即限制輸入的屬性名包含在 keyof
返回的聯合類型中。具體的使用方式如下:
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
在以上的 getProperty
函數中,我們通過 K extends keyof T
確保參數 key 一定是對象中含有的鍵,這樣就不會發生運行時錯誤。這是一個類型安全的解決方案,與簡單調用 let value = obj[key];
不同。
下面我們來看一下如何使用 getProperty
函數:
enum Difficulty {
Easy,
Intermediate,
Hard
}
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
let tsInfo = {
name: "Typescript",
supersetOf: "Javascript",
difficulty: Difficulty.Intermediate
}
let difficulty: Difficulty =
getProperty(tsInfo, 'difficulty'); // OK
let supersetOf: string =
getProperty(tsInfo, 'superset_of'); // Error
在以上示例中,對於 getProperty(tsInfo, 'superset_of')
這個表達式,TypeScript 編譯器會提示以下錯誤信息:
Argument of type '"superset_of"' is not assignable to parameter of type
'"difficulty" | "name" | "supersetOf"'.(2345)
很明顯通過使用泛型約束,在編譯階段我們就可以提前發現錯誤,大大提高了程序的健壯性和穩定性。接下來,我們來介紹一下泛型參數默認類型。
五、泛型參數默認類型
在 TypeScript 2.3 以後,我們可以爲泛型中的類型參數指定默認類型。當使用泛型時沒有在代碼中直接指定類型參數,從實際值參數中也無法推斷出類型時,這個默認類型就會起作用。
泛型參數默認類型與普通函數默認值類似,對應的語法很簡單,即 <T=Default Type>
,對應的使用示例如下:
interface A<T=string> {
name: T;
}
const strA: A = { name: "Semlinker" };
const numB: A<number> = { name: 101 };
泛型參數的默認類型遵循以下規則:
有默認類型的類型參數被認爲是可選的。
必選的類型參數不能在可選的類型參數後。
如果類型參數有約束,類型參數的默認類型必須滿足這個約束。
當指定類型實參時,你只需要指定必選類型參數的類型實參。未指定的類型參數會被解析爲它們的默認類型。
如果指定了默認類型,且類型推斷無法選擇一個候選類型,那麼將使用默認類型作爲推斷結果。
一個被現有類或接口合併的類或者接口的聲明可以爲現有類型參數引入默認類型。
一個被現有類或接口合併的類或者接口的聲明可以引入新的類型參數,只要它指定了默認類型。
六、泛型條件類型
在 TypeScript 2.8 中引入了條件類型,使得我們可以根據某些條件得到不同的類型,這裏所說的條件是類型兼容性約束。儘管以上代碼中使用了 extends
關鍵字,也不一定要強制滿足繼承關係,而是檢查是否滿足結構兼容性。
條件類型會以一個條件表達式進行類型關係檢測,從而在兩種類型中選擇其一:
T extends U ? X : Y
以上表達式的意思是:若 T
能夠賦值給 U
,那麼類型是 X
,否則爲 Y
。在條件類型表達式中,我們通常還會結合 infer
關鍵字,實現類型抽取:
interface Dictionary<T = any> {
[key: string]: T;
}
type StrDict = Dictionary<string>
type DictMember<T> = T extends Dictionary<infer V> ? V : never
type StrDictMember = DictMember<StrDict> // string
在上面示例中,當類型 T 滿足 T extends Dictionary
約束時,我們會使用 infer
關鍵字聲明瞭一個類型變量 V,並返回該類型,否則返回 never
類型。
在 TypeScript 中,
never
類型表示的是那些永不存在的值的類型。例如,never
類型是那些總是會拋出異常或根本就不會有返回值的函數表達式或箭頭函數表達式的返回值類型。另外,需要注意的是,沒有類型是
never
的子類型或可以賦值給never
類型(除了never
本身之外)。即使any
也不可以賦值給never
。
除了上述的應用外,利用條件類型和 infer
關鍵字,我們還可以方便地實現獲取 Promise 對象的返回值類型,比如:
async function stringPromise() {
return "Hello, Semlinker!";
}
interface Person {
name: string;
age: number;
}
async function personPromise() {
return { name: "Semlinker", age: 30 } as Person;
}
type PromiseType<T> = (args: any[]) => Promise<T>;
type UnPromisify<T> = T extends PromiseType<infer U> ? U : never;
type extractStringPromise = UnPromisify<typeof stringPromise>; // string
type extractPersonPromise = UnPromisify<typeof personPromise>; // Person
七、泛型工具類型
爲了方便開發者 TypeScript 內置了一些常用的工具類型,比如 Partial、Required、Readonly、Record 和 ReturnType 等。出於篇幅考慮,這裏我們只簡單介紹其中幾個常用的工具類型。
7.1 Partial
Partial<T>
的作用就是將某個類型裏的屬性全部變爲可選項 ?
。
定義:
/**
* node_modules/typescript/lib/lib.es5.d.ts
* Make all properties in T optional
*/
type Partial<T> = {
[P in keyof T]?: T[P];
};
在以上代碼中,首先通過 keyof T
拿到 T
的所有屬性名,然後使用 in
進行遍歷,將值賦給 P
,最後通過 T[P]
取得相應的屬性值。中間的 ?
號,用於將所有屬性變爲可選。
示例:
interface Todo {
title: string;
description: string;
}
function updateTodo(todo: Todo, fieldsToUpdate: Partial<Todo>) {
return { ...todo, ...fieldsToUpdate };
}
const todo1 = {
title: "organize desk",
description: "clear clutter"
};
const todo2 = updateTodo(todo1, {
description: "throw out trash"
});
在上面的 updateTodo
方法中,我們利用 Partial<T>
工具類型,定義 fieldsToUpdate
的類型爲 Partial<Todo>
,即:
{
title?: string | undefined;
description?: string | undefined;
}
7.2 Record
Record<K extends keyof any, T>
的作用是將 K
中所有的屬性的值轉化爲 T
類型。
定義:
/**
* node_modules/typescript/lib/lib.es5.d.ts
* Construct a type with a set of properties K of type T
*/
type Record<K extends keyof any, T> = {
[P in K]: T;
};
示例:
interface PageInfo {
title: string;
}
type Page = "home" | "about" | "contact";
const x: Record<Page, PageInfo> = {
about: { title: "about" },
contact: { title: "contact" },
home: { title: "home" }
};
7.3 Pick
Pick<T, K extends keyof T>
的作用是將某個類型中的子屬性挑出來,變成包含這個類型部分屬性的子類型。
定義:
// node_modules/typescript/lib/lib.es5.d.ts
/**
* From T, pick a set of properties whose keys are in the union K
*/
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
示例:
interface Todo {
title: string;
description: string;
completed: boolean;
}
type TodoPreview = Pick<Todo, "title" | "completed">;
const todo: TodoPreview = {
title: "Clean room",
completed: false
};
7.4 Exclude
Exclude<T, U>
的作用是將某個類型中屬於另一個的類型移除掉。
定義:
// node_modules/typescript/lib/lib.es5.d.ts
/**
* Exclude from T those types that are assignable to U
*/
type Exclude<T, U> = T extends U ? never : T;
如果 T
能賦值給 U
類型的話,那麼就會返回 never
類型,否則返回 T
類型。最終實現的效果就是將 T
中某些屬於 U
的類型移除掉。
示例:
type T0 = Exclude<"a" | "b" | "c", "a">; // "b" | "c"
type T1 = Exclude<"a" | "b" | "c", "a" | "b">; // "c"
type T2 = Exclude<string | number | (() => void), Function>; // string | number
7.5 ReturnType
ReturnType<T>
的作用是用於獲取函數 T
的返回類型。
定義:
// node_modules/typescript/lib/lib.es5.d.ts
/**
* Obtain the return type of a function type
*/
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;
示例:
type T0 = ReturnType<() => string>; // string
type T1 = ReturnType<(s: string) => void>; // void
type T2 = ReturnType<<T>() => T>; // {}
type T3 = ReturnType<<T extends U, U extends number[]>() => T>; // number[]
type T4 = ReturnType<any>; // any
type T5 = ReturnType<never>; // any
type T6 = ReturnType<string>; // Error
type T7 = ReturnType<Function>; // Error
簡單介紹了泛型工具類型,最後我們來介紹如何使用泛型來創建對象。
八、使用泛型創建對象
8.1 構造簽名
有時,泛型類可能需要基於傳入的泛型 T 來創建其類型相關的對象。比如:
class FirstClass {
id: number | undefined;
}
class SecondClass {
name: string | undefined;
}
class GenericCreator<T> {
create(): T {
return new T();
}
}
const creator1 = new GenericCreator<FirstClass>();
const firstClass: FirstClass = creator1.create();
const creator2 = new GenericCreator<SecondClass>();
const secondClass: SecondClass = creator2.create();
在以上代碼中,我們定義了兩個普通類和一個泛型類 GenericCreator<T>
。在通用的 GenericCreator
泛型類中,我們定義了一個名爲 create
的成員方法,該方法會使用 new 關鍵字來調用傳入的實際類型的構造函數,來創建對應的對象。但可惜的是,以上代碼並不能正常運行,對於以上代碼,在 TypeScript v3.9.2 編譯器下會提示以下錯誤:
'T' only refers to a type, but is being used as a value here.
這個錯誤的意思是:T
類型僅指類型,但此處被用作值。那麼如何解決這個問題呢?根據 TypeScript 文檔,爲了使通用類能夠創建 T 類型的對象,我們需要通過其構造函數來引用 T 類型。對於上述問題,在介紹具體的解決方案前,我們先來介紹一下構造簽名。
在 TypeScript 接口中,你可以使用 new
關鍵字來描述一個構造函數:
interface Point {
new (x: number, y: number): Point;
}
以上接口中的 new (x: number, y: number)
我們稱之爲構造簽名,其語法如下:
ConstructSignature:
new
TypeParametersopt(
ParameterListopt)
TypeAnnotationopt
在上述的構造簽名中,TypeParametersopt
、ParameterListopt
和 TypeAnnotationopt
分別表示:可選的類型參數、可選的參數列表和可選的類型註解。與該語法相對應的幾種常見的使用形式如下:
new C
new C ( ... )
new C < ... > ( ... )
介紹完構造簽名,我們再來介紹一個與之相關的概念,即構造函數類型。
8.2 構造函數類型
在 TypeScript 語言規範中這樣定義構造函數類型:
An object type containing one or more construct signatures is said to be a constructor type. Constructor types may be written using constructor type literals or by including construct signatures in object type literals.
通過規範中的描述信息,我們可以得出以下結論:
包含一個或多個構造簽名的對象類型被稱爲構造函數類型;
構造函數類型可以使用構造函數類型字面量或包含構造簽名的對象類型字面量來編寫。
那麼什麼是構造函數類型字面量呢?構造函數類型字面量是包含單個構造函數簽名的對象類型的簡寫。具體來說,構造函數類型字面量的形式如下:
new < T1, T2, ... > ( p1, p2, ... ) => R
該形式與以下對象類型字面量是等價的:
{ new < T1, T2, ... > ( p1, p2, ... ) : R }
下面我們來舉個實際的示例:
// 構造函數類型字面量
new (x: number, y: number) => Point
等價於以下對象類型字面量:
{
new (x: number, y: number): Point;
}
8.3 構造函數類型的應用
在介紹構造函數類型的應用前,我們先來看個例子:
interface Point {
new (x: number, y: number): Point;
x: number;
y: number;
}
class Point2D implements Point {
readonly x: number;
readonly y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
}
const point: Point = new Point2D(1, 2);
對於以上的代碼,TypeScript 編譯器會提示以下錯誤信息:
Class 'Point2D' incorrectly implements interface 'Point'.
Type 'Point2D' provides no match for the signature 'new (x: number, y: number): Point'.
相信很多剛接觸 TypeScript 不久的小夥伴都會遇到上述的問題。要解決這個問題,我們就需要把對前面定義的 Point
接口進行分離,即把接口的屬性和構造函數類型進行分離:
interface Point {
x: number;
y: number;
}
interface PointConstructor {
new (x: number, y: number): Point;
}
完成接口拆分之後,除了前面已經定義的 Point2D
類之外,我們又定義了一個 newPoint
工廠函數,該函數用於根據傳入的 PointConstructor 類型的構造函數,來創建對應的 Point 對象。
class Point2D implements Point {
readonly x: number;
readonly y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
}
function newPoint(
pointConstructor: PointConstructor,
x: number,
y: number
): Point {
return new pointConstructor(x, y);
}
const point: Point = newPoint(Point2D, 1, 2);
8.4 使用泛型創建對象
瞭解完構造簽名和構造函數類型之後,下面我們來開始解決上面遇到的問題,首先我們需要重構一下 create
方法,具體如下所示:
class GenericCreator<T> {
create<T>(c: { new (): T }): T {
return new c();
}
}
在以上代碼中,我們重新定義了 create
成員方法,根據該方法的簽名,我們可以知道該方法接收一個參數,其類型是構造函數類型,且該構造函數不包含任何參數,調用該構造函數後,會返回類型 T 的實例。
如果構造函數含有參數的話,比如包含一個 number
類型的參數時,我們可以這樣定義 create 方法:
create<T>(c: { new(a: number): T; }, num: number): T {
return new c(num);
}
更新完 GenericCreator
泛型類,我們就可以使用下面的方式來創建 FirstClass
和 SecondClass
類的實例:
const creator1 = new GenericCreator<FirstClass>();
const firstClass: FirstClass = creator1.create(FirstClass);
const creator2 = new GenericCreator<SecondClass>();
const secondClass: SecondClass = creator2.create(SecondClass);
九、參考資源
typescript-generics
typescript-generics-explained
▼
往期精彩回顧
▼
你不知道的 Blob
聚焦全棧,專注分享 Angular、TypeScript、Node.js 、Spring 技術棧等全棧乾貨。
回覆 0 進入重學TypeScript學習羣
回覆 1 獲取全棧修仙之路博客地址