跳至主要内容

[Day03] TS:泛型就。很。泛!用 extends 來加上一點限制吧

昨天我們提到了泛型(generics)的使用,但泛型就像一個型別為 any 的變數一樣,使用者愛帶什麼型別都可以,基本上是沒有型別上的限制,但有些時候我們想要使用泛型,讓函式或 type alias 可以不只適用於一種型別,但有希望能對使用者帶入的型別有一點限制的話,可以怎麼做呢?

在 TypeScript 中提供了「泛型限制(Generic Constraints)」的用法,語法上只需要使用 extends 就可以了!

一般 TypeScript 的初學者看到 extends 時,直覺上會想到的是可以拿來擴展某一個介面(interfaces)使用,像是這樣:

interface Person {
age: number;
occupation: string;
}

// 使用 extends 來擴展另一個 interface
interface Author extends Person {
firstName: string;
lastName: string;
}

就可以建立一個新的 Author interface,且讓它帶有 Person 中所定義的屬性:

const aaron: Author = {
age: 33,
occupation: 'developer',
firstName: 'PJ',
lastName: 'Chen',
};

或者另一個很多人會想到的是 JavaScript 中「類別繼承(class extends)」的使用,例如:

class Square {
constructor(public width: number) {}
}

// 使用 extends 來繼承另一個 class 的屬性
class Rectangle extends Square {
constructor(
width: number,
public height: number,
) {
super(width);
}
}

const square = new Square(10);
const rectangle = new Rectangle(10, 20);

使用 extends 來限制泛型可接受的型別

然而,在 TypeScript 中的 extends 除了上述用法外,還被賦予了更多的功能,像是可以用來限制泛型可被帶入的型別(generic constraints)或是作為型別的條件判斷(conditional types)。在這種情況下,extends 比較好理解的中文應該是「需要滿足 ooo」,但更精確的是指「是 ooo 的子集合」。今天就先來看一下如何透過 extends 來限制泛型可被帶入的型別。

extends 在建立 Type Utility 是非常容易用到,因此我們在後面幾天也會一直看到它。

先來看一下昨天寫的函式:

function getFirstElement<T>(arr: T[]): T {
const [firstElement] = arr;
return firstElement;
}

假設現在我們希望限制這個 T 只能是數值(number)的話,可以搭配 extends 寫成 <T extends number>,意思就是限制使用者帶入的泛型 「T 需要時 number 的子集合」:

Generic Constraints

更精確的來說,應該是指「T 要是 number 的子集合(subset)」,如果用集合的圖示來表達的話,會像這樣:

Generic Constraints

這時候如果我們在呼叫 getFirstElement 時,帶入的卻是 string[] 的話,TS 就會報錯,因為 T 現在是 string,但 T 並是 number 的子集合:

Generic Constraints

畫成圖的概念會像這樣:

Generic Constraints

同樣的,如果是希望泛型 T 只能帶入 string 或 number 的話,則可以寫成 <T extends number | string>,意思就是 T 這個泛型不能什麼都接受,它需要時 string 或 number 的子集合才行,像是這樣:

Generic Constraints

這時候如果使用者帶入的泛型不是 number 或 string 的話 TS 就會報錯。例如,下圖帶的是 boolean:

Generic Constraints

到這裡你可能雖然知道了「喔~原來 extends 還能當成『需要滿足 ooo』」的意思,但卻還不知道實際的使用時機。

關於這點我們會在後面幾天看到很多實際的例子,這裡先提供一個簡單的範例,假設有一個函式可以輸出姓名,它可以:

  1. 接受「任何型別的物件」當作參數
  2. 但因為它要輸出姓名,所以參數本身有一個限制,就是物件中至少要有 firstNamelastName 這兩個屬性

一開始可能會這樣寫這個 function:

function logPersonName<T>(person: T) {
return `${person.firstName} ${person.lastName}`;
}

但這時候因為 TypeScript 沒辦法確保泛型 T 中一定有 firstNamelastName 這兩個屬性,因此會報錯:

Generic Constraints

這時候就可以透過 generic constraints 的方式,限制使用者帶入的泛型的型別至少要包含 firstNamelastName 這兩個屬性,其他的屬性 TypeScript 則不管。

可以寫成這樣:

interface PersonName {
firstName: string;
lastName: string;
}

// 使用 T extends PersonName,限制 T 一定要是 PersonName 型別的子集合
function logPersonName<T extends PersonName>(person: T) {
return `${person.firstName} ${person.lastName}`;
}

這時候因為能夠確保帶入 function 參數的泛型 T 一定有 firstNamelastName 這兩個屬性,所以 TypeScript 就不會再報錯,使用者也可以帶入任何物件,只要這個物件中包含這兩個必要的屬性:

// 只要使用者帶入的物件包含 firstName 和 lastName 就好(符合對泛型的限制)
// 其他多餘的物件屬性 TypeScript 不會管

logPersonName({
firstName: 'Aaron',
lastName: 'Chen',
occupation: 'developer',
});

logPersonName({
firstName: 'PJ',
lastName: 'Chen',
favorite: 'smart doctor',
});

但如果帶入的物件少了 firstNamelastName ,則 TS 就會直接報錯:

Generic Constraints

Primitive type 和 Object 的表現會「感覺」不太一樣

如果有仔細閱讀的讀者,應該會發現讀到這裡好像哪裡怪怪的,最一開始的例子是 Primitive Type,如果用 T extends number 的話,這個 T 就只能是 number,不能是 string | number;但如果是 Object 的話,當使用 T extends {firstName: string},這時候即使 T 是 {firstName: string, lastName: string} 也是可以的。這樣的情況在 TypeScript 中的 Unions and Intersection Types 也可以看到類似的現象。

Type Alias 中的 Generics 中同樣適用 extends 來限制泛型

關於使用 extends 來限制泛型可被接受型別的用法同樣適用在 type alias 上,例如:

type PersonNameType {
firstName: string;
lastName: string
}

type Person<T extends PersonNameType> = T;

意思一樣是泛型 T 可以是任何型別,但它至少要是 PersonName 這個型別的子集合,也就是要有 firstNamelastName 這兩個屬性。使用時會像這樣:

/**
* T 等於
* {
* firstName: string;
* lastName: string;
* occupation: string;
* }
* */
const pjchender: Person<{
firstName: string;
lastName: string;
occupation: string;
}> = {
firstName: 'PJ',
lastName: 'Chen',
occupation: 'developer',
};

後面我們會再看到更多例子,到時候會更清楚 extends 在泛型中的使用。

範例程式碼

Sample Code @ TypeScript Playground

參考資料

  • Generics @ TypeScript > Type Manipulations