泛型

軟體工程的一個重要部分是構建不僅定義良好且一致的 API,而且還要具有可重用性。能夠處理當今資料以及未來資料的元件,將為您構建大型軟體系統提供最靈活的能力。

在 C# 和 Java 等語言中,建立可重用元件的常用工具之一是泛型,即能夠建立一個可以處理多種型別而非單一型別的元件。這使得使用者可以消費這些元件並使用他們自己的型別。

泛型的 Hello World

首先,讓我們來看泛型的“Hello World”:恆等函式(identity function)。恆等函式是一個會返回任何傳入內容的函式。你可以將其類比為 echo 命令。

如果不使用泛型,我們要麼必須給恆等函式指定一個具體的型別:

ts
function identity(arg: number): number {
return arg;
}
Try

要麼,我們可以使用 any 型別來描述恆等函式:

ts
function identity(arg: any): any {
return arg;
}
Try

雖然使用 any 在某種程度上是泛型的,因為它使函式能夠接受 arg 的任何和所有型別,但我們在函式返回時實際上丟失了關於該型別是什麼的資訊。如果我們傳入一個數字,我們所知道的唯一資訊就是可以返回任何型別。

相反,我們需要一種方法來捕獲引數的型別,以便可以用它來表示返回的內容。在這裡,我們將使用型別變數,這是一種作用於型別而不是值的特殊變數。

ts
function identity<Type>(arg: Type): Type {
return arg;
}
Try

現在,我們為恆等函式添加了一個型別變數 Type。這個 Type 允許我們捕獲使用者提供的型別(例如 number),以便我們稍後可以使用該資訊。在這裡,我們再次使用 Type 作為返回型別。透過檢查,我們可以看到相同的型別被用於引數和返回型別。這使我們能夠將型別資訊從函式的一側傳入,並從另一側傳出。

我們稱這個版本的 identity 函式是泛型的,因為它適用於多種型別。與使用 any 不同,它也和第一個使用數字作為引數和返回型別的 identity 函式一樣精確(即它不會丟失任何資訊)。

一旦我們編寫了泛型恆等函式,我們可以透過以下兩種方式之一來呼叫它。第一種方式是將所有引數,包括型別引數,傳給函式:

ts
let output = identity<string>("myString");
let output: string
Try

這裡我們明確將 Type 設定為 string 作為函式呼叫的引數之一,並使用 <> 包裹引數而不是 ()

第二種方式也許是最常見的。這裡我們使用型別引數推斷——也就是說,我們希望編譯器根據我們傳入的引數型別自動為我們設定 Type 的值:

ts
let output = identity("myString");
let output: string
Try

注意,我們不必在尖括號(<>)中顯式傳遞型別;編譯器只是查看了值 "myString",並將 Type 設定為其型別。雖然型別引數推斷是一個有助於保持程式碼簡潔和易讀的工具,但在編譯器無法推斷型別時(例如在更復雜的例子中),你可能需要像之前的例子那樣顯式傳遞型別引數。

使用泛型型別變數

當你開始使用泛型時,你會注意到當你建立像 identity 這樣的泛型函式時,編譯器會強制要求你在函式體內正確使用任何泛型型別引數。也就是說,你需要像對待“任何和所有型別”一樣對待這些引數。

讓我們以之前的 identity 函式為例:

ts
function identity<Type>(arg: Type): Type {
return arg;
}
Try

如果我們還想在每次呼叫時將引數 arg 的長度記錄到控制檯呢?我們可能會嘗試這樣寫:

ts
function loggingIdentity<Type>(arg: Type): Type {
console.log(arg.length);
Property 'length' does not exist on type 'Type'.2339Property 'length' does not exist on type 'Type'.
return arg;
}
Try

當我們這樣做時,編譯器會報錯,因為我們使用了 arg.length 成員,但我們並沒有在任何地方宣告 arg 具有該成員。請記住,我們之前說過這些型別變數代表的是任何和所有型別,所以使用此函式的人可能傳入了一個 number,而 number 並沒有 .length 成員。

假設我們實際上是想讓這個函式處理 Type 的陣列而不是直接處理 Type。既然我們是在處理陣列,那麼 .length 成員應該是可用的。我們可以像建立其他型別的陣列那樣描述它:

ts
function loggingIdentity<Type>(arg: Type[]): Type[] {
console.log(arg.length);
return arg;
}
Try

你可以這樣讀取 loggingIdentity 的型別:“泛型函式 loggingIdentity 接收一個型別引數 Type,以及一個引數 arg(它是 Type 的陣列),並返回一個 Type 的陣列。” 如果我們傳入一個數字陣列,我們將得到一個數字陣列返回,因為 Type 會繫結到 number。這允許我們將泛型型別變數 Type 用作我們處理的型別的一部分,而不是整個型別,從而賦予我們更大的靈活性。

我們也可以用這種方式編寫同樣的示例:

ts
function loggingIdentity<Type>(arg: Array<Type>): Array<Type> {
console.log(arg.length); // Array has a .length, so no more error
return arg;
}
Try

你可能已經熟悉了其他語言中這種風格的型別。在下一節中,我們將介紹如何建立像 Array<Type> 這樣的自定義泛型型別。

泛型型別

在前幾節中,我們建立了適用於多種型別的泛型恆等函式。在本節中,我們將探討函式本身的型別以及如何建立泛型介面。

泛型函式的型別與非泛型函式很像,型別引數列在最前面,類似於函式宣告:

ts
function identity<Type>(arg: Type): Type {
return arg;
}
 
let myIdentity: <Type>(arg: Type) => Type = identity;
Try

我們也可以在型別中使用不同的名稱作為泛型型別引數,只要型別變數的數量和使用方式對齊即可。

ts
function identity<Type>(arg: Type): Type {
return arg;
}
 
let myIdentity: <Input>(arg: Input) => Input = identity;
Try

我們還可以將泛型型別編寫為物件字面量型別的呼叫簽名:

ts
function identity<Type>(arg: Type): Type {
return arg;
}
 
let myIdentity: { <Type>(arg: Type): Type } = identity;
Try

這引導我們編寫第一個泛型介面。讓我們從上一個例子中獲取物件字面量並將其移動到介面中:

ts
interface GenericIdentityFn {
<Type>(arg: Type): Type;
}
 
function identity<Type>(arg: Type): Type {
return arg;
}
 
let myIdentity: GenericIdentityFn = identity;
Try

在類似的例子中,我們可能想將泛型引數設為整個介面的引數。這讓我們能清楚地看到我們泛型化的型別(例如 Dictionary<string> 而不僅僅是 Dictionary)。這使得型別引數對介面的所有其他成員可見。

ts
interface GenericIdentityFn<Type> {
(arg: Type): Type;
}
 
function identity<Type>(arg: Type): Type {
return arg;
}
 
let myIdentity: GenericIdentityFn<number> = identity;
Try

請注意,我們的示例已經發生了變化。我們不再描述一個泛型函式,現在我們擁有的是作為泛型型別一部分的非泛型函式簽名。當我們使用 GenericIdentityFn 時,現在還需要指定相應的型別引數(這裡是 number),從而有效地鎖定了底層呼叫簽名將使用的型別。理解何時將型別引數直接放在呼叫簽名上,以及何時將其放在介面本身上,將有助於描述型別的哪些方面是泛型的。

除了泛型介面,我們還可以建立泛型類。請注意,無法建立泛型列舉和名稱空間。

泛型類

泛型類的形狀與泛型介面類似。泛型類在類名後有尖括號(<>)括起來的泛型型別引數列表。

ts
class GenericNumber<NumType> {
zeroValue: NumType;
add: (x: NumType, y: NumType) => NumType;
}
 
let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function (x, y) {
return x + y;
};
Try

這是 GenericNumber 類的一個非常字面的用法,但你可能已經注意到,沒有任何東西限制它只能使用 number 型別。我們本來可以使用 string 甚至是更復雜的物件。

ts
let stringNumeric = new GenericNumber<string>();
stringNumeric.zeroValue = "";
stringNumeric.add = function (x, y) {
return x + y;
};
 
console.log(stringNumeric.add(stringNumeric.zeroValue, "test"));
Try

就像介面一樣,將型別引數放在類本身上,可以確保類的所有屬性都使用相同的型別。

正如我們在關於類的章節中所述,類有兩種型別:靜態部分和例項部分。泛型類僅在其例項部分是泛型的,而不是靜態部分,因此在使用類時,靜態成員不能使用類的型別引數。

泛型約束

如果你還記得之前的例子,有時你可能想要編寫一個適用於一組型別的泛型函式,前提是你對這組型別具有一些能力上的瞭解。在我們的 loggingIdentity 例子中,我們希望能夠訪問 arg.length 屬性,但編譯器無法證明每個型別都有 .length 屬性,因此它警告我們不能做出這種假設。

ts
function loggingIdentity<Type>(arg: Type): Type {
console.log(arg.length);
Property 'length' does not exist on type 'Type'.2339Property 'length' does not exist on type 'Type'.
return arg;
}
Try

我們不想處理任何和所有型別,而是希望將此函式約束為適用於具有 .length 屬性的任何型別。只要型別具有此成員,我們就允許它,但要求它至少包含此成員。為此,我們必須將我們的要求作為 Type 可能是什麼的約束列出來。

為了實現這一點,我們將建立一個描述我們約束的介面。在這裡,我們將建立一個具有單個 .length 屬性的介面,然後使用這個介面和 extends 關鍵字來表示我們的約束:

ts
interface Lengthwise {
length: number;
}
 
function loggingIdentity<Type extends Lengthwise>(arg: Type): Type {
console.log(arg.length); // Now we know it has a .length property, so no more error
return arg;
}
Try

因為泛型函式現在受到了約束,它將不再適用於任何和所有型別:

ts
loggingIdentity(3);
Argument of type 'number' is not assignable to parameter of type 'Lengthwise'.2345Argument of type 'number' is not assignable to parameter of type 'Lengthwise'.
Try

相反,我們需要傳入型別具有所有必需屬性的值:

ts
loggingIdentity({ length: 10, value: 3 });
Try

在泛型約束中使用型別引數

你可以宣告一個受另一個型別引數約束的型別引數。例如,這裡我們想從一個物件中根據名稱獲取一個屬性。我們想確保不會意外獲取到 obj 上不存在的屬性,因此我們將在兩個型別之間設定一個約束:

ts
function getProperty<Type, Key extends keyof Type>(obj: Type, key: Key) {
return obj[key];
}
 
let x = { a: 1, b: 2, c: 3, d: 4 };
 
getProperty(x, "a");
getProperty(x, "m");
Argument of type '"m"' is not assignable to parameter of type '"a" | "b" | "c" | "d"'.2345Argument of type '"m"' is not assignable to parameter of type '"a" | "b" | "c" | "d"'.
Try

在泛型中使用類型別

在 TypeScript 中使用泛型建立工廠時,有必要透過其建構函式引用類型別。例如:

ts
function create<Type>(c: { new (): Type }): Type {
return new c();
}
Try

一個更高階的例子使用原型屬性來推斷和約束建構函式與類型別的例項側之間的關係。

ts
class BeeKeeper {
hasMask: boolean = true;
}
 
class ZooKeeper {
nametag: string = "Mikle";
}
 
class Animal {
numLegs: number = 4;
}
 
class Bee extends Animal {
numLegs = 6;
keeper: BeeKeeper = new BeeKeeper();
}
 
class Lion extends Animal {
keeper: ZooKeeper = new ZooKeeper();
}
 
function createInstance<A extends Animal>(c: new () => A): A {
return new c();
}
 
createInstance(Lion).keeper.nametag;
createInstance(Bee).keeper.hasMask;
Try

這種模式被用於驅動 Mixins 設計模式。

泛型引數預設值

透過為泛型型別引數宣告預設值,你可以使指定相應的型別引數變為可選。例如,一個建立新的 HTMLElement 的函式。在不帶引數的情況下呼叫該函式會生成一個 HTMLDivElement;以一個元素作為第一個引數呼叫該函式會生成該引數型別的元素。你還可以選擇傳入一個子元素列表。以前你必須將該函式定義為:

ts
declare function create(): Container<HTMLDivElement, HTMLDivElement[]>;
declare function create<T extends HTMLElement>(element: T): Container<T, T[]>;
declare function create<T extends HTMLElement, U extends HTMLElement>(
element: T,
children: U[]
): Container<T, U[]>;
Try

使用泛型引數預設值,我們可以將其簡化為:

ts
declare function create<T extends HTMLElement = HTMLDivElement, U extends HTMLElement[] = T[]>(
element?: T,
children?: U
): Container<T, U>;
 
const div = create();
const div: Container<HTMLDivElement, HTMLDivElement[]>
 
const p = create(new HTMLParagraphElement());
const p: Container<HTMLParagraphElement, HTMLParagraphElement[]>
Try

泛型引數預設值遵循以下規則:

  • 如果型別引數有預設值,則視為可選。
  • 必需的型別引數不能跟在可選的型別引數之後。
  • 如果型別引數有約束,則該引數的預設型別必須滿足該約束。
  • 指定型別引數時,你只需要為必需的型別引數指定型別引數。未指定的型別引數將解析為其預設型別。
  • 如果指定了預設型別且推斷無法選擇候選型別,則推斷為預設型別。
  • 與現有類或介面宣告合併的類或介面宣告可以為現有的型別引數引入預設值。
  • 與現有類或介面宣告合併的類或介面宣告可以引入新的型別引數,只要它指定了預設值。

可變性註解

這是一項用於解決非常具體問題的高階功能,僅應在你確定有理由使用它時才使用。

協變(Covariance)和逆變(Contravariance)是型別理論術語,用於描述兩個泛型型別之間的關係。以下是關於該概念的簡要入門。

例如,如果你有一個表示可以 make(生產)某種型別的物件的介面:

ts
interface Producer<T> {
make(): T;
}

當預期為 Producer<Animal> 時,我們可以使用 Producer<Cat>,因為 CatAnimal。這種關係稱為協變:從 Producer<T>Producer<U> 的關係與從 TU 的關係相同。

相反,如果你有一個可以 consume(消費)某種型別的介面:

ts
interface Consumer<T> {
consume: (arg: T) => void;
}

那麼當預期為 Consumer<Cat> 時,我們可以使用 Consumer<Animal>,因為任何能夠接受 Animal 的函式也必須能夠接受 Cat。這種關係稱為逆變:從 Consumer<T>Consumer<U> 的關係與從 UT 的關係相同。請注意與協變相比方向的逆轉!這就是為什麼逆變會“自我抵消”而協變不會的原因。

在像 TypeScript 這樣的結構化型別系統中,協變和逆變是從型別定義中自然湧現的行為。即使沒有泛型,我們也會看到協變(和逆變)關係。

ts
interface AnimalProducer {
make(): Animal;
}
// A CatProducer can be used anywhere an
// Animal producer is expected
interface CatProducer {
make(): Cat;
}

TypeScript 具有結構化型別系統,因此在比較兩個型別時(例如檢視是否可以在預期 Producer<Animal> 的地方使用 Producer<Cat>),通常的演算法是結構化地展開這兩個定義並比較它們的結構。然而,可變性(Variance)允許進行一種極其有用的最佳化:如果 Producer<T>T 是協變的,那麼我們只需檢查 CatAnimal 即可,因為我們知道它們將具有與 Producer<Cat>Producer<Animal> 相同的關係。

注意,此邏輯僅在檢查同一型別的兩個例項化時才能使用。如果我們有 Producer<T>FastProducer<U>,則無法保證 TU 一定引用這些型別中的相同位置,因此此檢查將始終結構化執行。

因為可變性是結構化型別的自然湧現屬性,TypeScript 會自動推斷每個泛型型別的可變性。在極少數情況下,涉及某些種類的迴圈型別時,這種測量可能不準確。如果發生這種情況,你可以向型別引數新增可變性註解以強制執行特定的可變性:

ts
// Contravariant annotation
interface Consumer<in T> {
consume: (arg: T) => void;
}
// Covariant annotation
interface Producer<out T> {
make(): T;
}
// Invariant annotation
interface ProducerConsumer<in out T> {
consume: (arg: T) => void;
make(): T;
}

僅當你編寫的可變性與結構上應該發生的可變性一致時才這樣做。

永遠不要編寫與結構可變性不匹配的可變性註解!

至關重要的是要重申,可變性註解僅在基於例項化的比較期間生效。它們在結構比較期間無效。例如,你不能使用可變性註解來“強制”一個型別實際上是不變的(invariant):

ts
// DON'T DO THIS - variance annotation
// does not match structural behavior
interface Producer<in out T> {
make(): T;
}
// Not a type error -- this is a structural
// comparison, so variance annotations are
// not in effect
const p: Producer<string | number> = {
make(): number {
return 42;
}
}

在這裡,物件字面量的 make 函式返回 number,我們可能會預期這會導致錯誤,因為 number 不是 string | number。然而,這不是基於例項化的比較,因為物件字面量是一個匿名型別,而不是 Producer<string | number>

可變性註解不會改變結構行為,且僅在特定情況下被查詢。

非常重要的一點是,只有當你絕對知道自己為什麼這樣做、它們的侷限性是什麼以及何時不生效時,才編寫可變性註解。TypeScript 是使用基於例項化的比較還是結構比較不是一種指定的行為,並且可能會因為正確性或效能原因在版本之間發生變化,因此你應該只在可變性註解與型別的結構行為匹配時才編寫它們。不要使用可變性註解來嘗試“強制”特定的可變性;這會導致程式碼中出現不可預知的行為。

不要編寫與型別的結構行為不匹配的可變性註解。

請記住,TypeScript 可以自動從你的泛型型別中推斷可變性。幾乎不需要編寫可變性註解,並且你應該僅在確定有特定需求時才這樣做。可變性註解不會改變型別的結構行為,並且根據情況,你可能會在期望基於例項化的比較時看到結構比較。可變性註解不能用於修改型別在這些結構上下文中的行為,也不應該在註解與結構定義不一致時編寫。因為這很難做到正確,而且 TypeScript 在絕大多數情況下都能正確推斷可變性,所以你不應該在常規程式碼中編寫可變性註解。

不要試圖使用可變性註解來改變型別檢查行為;這不是它們的用途。

可能會發現臨時的可變性註解在“型別除錯”情況下很有用,因為可變性註解會被檢查。如果標註的可變性明顯錯誤,TypeScript 將發出錯誤。

ts
// Error, this interface is definitely contravariant on T
interface Foo<out T> {
consume: (arg: T) => void;
}

然而,允許可變性註解更嚴格(例如,如果實際可變性是協變的,則 in out 是有效的)。確保在除錯完成後刪除你的可變性註解。

最後,如果你試圖最大限度地提高型別檢查效能,並且運行了分析器,並且確定了特定的型別很慢,並且確定了可變性推斷特別慢,並且仔細驗證了你要編寫的可變性註解,你可能會在極其複雜的型別中透過新增可變性註解獲得小幅的效能提升。

不要試圖使用可變性註解來改變型別檢查行為;這不是它們的用途。

TypeScript 文件是一個開源專案。透過傳送 Pull Request 幫助我們改進這些頁面 ❤

此頁面的貢獻者
OTOrta Therox (26)
NKNavneet Karnani (2)
DGMDr. Galambos Máté (1)
SPSantiago Palladino (1)
PPanosMagic32 (1)
12+

最後更新:2026 年 3 月 27 日