[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 的子集合」:
更精確的來說,應該是指「T
要是 number
的子集合(subset)」,如果用集合的圖示來表達的話,會像這樣:
這時候如果我們在呼叫 getFirstElement
時,帶入的卻是 string[]
的話,TS 就會報錯,因為 T 現在是 string,但 T 並是 number 的子集合:
畫成圖的概念會像這樣:
同樣的,如果是希望泛型 T 只能帶入 string 或 number 的話,則可以寫成 <T extends number | string>
,意思就是 T 這個泛型不能什麼都接受,它需要時 string 或 number 的子集合才行,像是這樣:
這時候如果使用者帶入的泛型不是 number 或 string 的話 TS 就會報錯。例如,下圖帶的是 boolean:
到這裡你可能雖然知道了「喔~原來 extends
還能當成『需要滿足 ooo』」的意思,但卻還不知道實際的使用時機。
關於這點我們會在後面幾天看到很多實際的例子,這裡先提供一個簡單的範例,假設有一個函式可以輸出姓名,它可以:
- 接受「任何型別的物件」當作參數
- 但因為它要輸出姓名,所以參數本身有一個限制,就是物件中至少要有
firstName
和lastName
這兩個屬性
一開始可能會這樣寫這個 function:
function logPersonName<T>(person: T) {
return `${person.firstName} ${person.lastName}`;
}
但這時候因為 TypeScript 沒辦法確保泛型 T
中一定有 firstName
和 lastName
這兩個屬性,因此會報錯:
這時候就可以透過 generic constraints 的方式,限制使用者帶入的泛型的型別至少要包含 firstName
和 lastName
這兩個屬性,其他的屬性 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 一定有 firstName
和 lastName
這兩個屬性,所以 TypeScript 就不會再報錯,使用者也可以帶入任何物件,只要這個物件中包含這兩個必要的屬性:
// 只要使用者帶入的物件包含 firstName 和 lastName 就好(符合對泛型的限制)
// 其他多餘的物件屬性 TypeScript 不會管
logPersonName({
firstName: 'Aaron',
lastName: 'Chen',
occupation: 'developer',
});
logPersonName({
firstName: 'PJ',
lastName: 'Chen',
favorite: 'smart doctor',
});
但如果帶入的物件少了 firstName
或 lastName
,則 TS 就會直接報錯:
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
這個型別的子集合,也就是要有 firstName
和 lastName
這兩個屬性。使用時會像這樣:
/**
* 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