iT邦幫忙

2021 iThome 鐵人賽

DAY 3
2
Modern Web

React 前端工程師的兩把刷子系列 第 3

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

  • 分享至 

  • xImage
  •  

昨天我們提到了泛型(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 在泛型中的使用。

範例程式碼

https://tsplay.dev/Wv89rN @ TypeScript Playground

參考資料

  • Generics @ TypeScript > Type Manipulations

上一篇
[Day02] TS: 泛型(Generics)能幹嘛?
下一篇
[Day04] TS:如何把物件型別的所有屬性名稱取出變成 union type?試試看 keyof 吧!
系列文
React 前端工程師的兩把刷子30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中
0

這最好是日記...有備而來吧...

pjchender iT邦新手 3 級 ‧ 2021-09-18 01:37:54 檢舉

真的是剛剛寫的啦...
之前 iphone 就還沒發表吼

pjchender iT邦新手 3 級 ‧ 2021-09-18 01:38:51 檢舉

我還跟隊友說我這樣不行,明明講一句話不用十分鐘就解釋完的東西,怎麼寫了快 2hr XD

Ken Chen iT邦新手 4 級 ‧ 2021-09-18 01:46:24 檢舉

用講得比較快XD

0
TD
iT邦新手 4 級 ‧ 2021-09-18 10:34:13

意思一樣是泛型 T 可以是任何型別,但它至少要滿足 PersonName 這個屬性,也就是要有 firstNamelastName 這兩個屬性。

這裡的 PersonName 正確來說是型別對吧?

另外在上面的例子 PersonName 是 interface,後來變成 type。雖然知道兩者常互換使用,不過有沒有什麼使用上的原則或限制呢?

pjchender iT邦新手 3 級 ‧ 2021-09-18 15:24:45 檢舉

PersonName 是型別沒錯喔,已修正,謝謝 td!

根據 TypeScript 官方網站的描述:

For the most part, you can choose based on personal preference, and TypeScript will tell you if it needs something to be the other kind of declaration. If you would like a heuristic, use interface until you need to use features from type. (Everydate Types)

以我個人的經驗來說,多數時候會用 interface,但如果是要用一個 Type 來建立另一個 Type 的時候(Type Utility),則會用 Type 來做定義。

TD iT邦新手 4 級 ‧ 2021-09-18 22:51:17 檢舉

原來如此!

0
TD
iT邦新手 4 級 ‧ 2021-09-18 10:34:56

另外想請問這個圖是怎麼做出來的 XD
https://ithelp.ithome.com.tw/upload/images/20210918/20116003bAZPxQpray.png

ctc2096 iT邦新手 5 級 ‧ 2021-09-18 14:18:58 檢舉

程式碼的部分應該是用 Carbon

TD iT邦新手 4 級 ‧ 2021-09-18 15:04:17 檢舉

ctc2096感謝!

pjchender iT邦新手 3 級 ‧ 2021-09-18 15:26:19 檢舉

沒錯喔,是用 Carbon,謝謝 @ctc2096

我要留言

立即登入留言