跳至主要内容

[Day06] TS:整合前幾天所學,來寫個 Generic Functions 吧!

這幾天的內容中,我們已經學到了幾個重點:

  • 泛型(generics)的使用
  • 使用 extends 限制泛型
  • keyof 的使用
  • Indexed Access Types 的使用

現在讓我們結合這幾天的內容來試著寫個簡單的函式,這個函式名稱是 getObjValue,功能很簡單,它可以接受兩個參數,第一個參數是物件,第二個參數是該物件中的 key,回傳的內容就是物件中對應到該 key 的 value。最終寫起來會像這樣:

generics type

使用方式會像這樣:

const user = {
firstName: 'PJ',
lastName: 'Chen',
age: 35,
isAdmin: true,
};

const product = {
name: 'iPad mini',
price: 14900,
manufacturer: 'Apple',
madeIn: 'China',
};

const isAdmin = getObjValue(user, 'isAdmin'); // true
const manufacturer = getObjValue(product, 'manufacturer'); // 'Apple'

同樣的,如果你原本就已經看得懂上面這個函式的寫法,歡迎直接左轉去看我同事 Kyle 「今晚,我想來點 Web 前端效能優化大補帖!」的精彩文章!。

試著寫出這個函式

首先,讓我們先不管 TypeScript,用原有 JavaScript 的知識寫出這個函式,寫起來會像這樣:

const getObjValue = (obj, key) => obj[key];

但這個函式在沒有型別保護的情況下,很有可能會呼叫到了根本不存在該 obj 中的 key,進而取不到值。例如,我們以為 user 物件中有 title 這個屬性,但實際上卻沒有:

// 在沒有型別保護的情況下,很有可能會呼叫到了根本不存在該 obj 中的 key,進而取不到值
const title = getObjValue(user, 'title'); // undefined

如果我們又直接拿這個 title 去做其他的操作,就有可能會發生錯誤。

現在請讀者試著把它改成 TypeScript 的寫法。

你可以把物件 user 和函式 getObjValue 貼到 TypeScript Playground 中練習看看。

試著定義函式的型別

預設的情況下,如果我們沒有定義參數的型別,它的型別會是 any

TypeScript Type Utility

但這並不是我們想要的情況,因為 any 代表 TypeScript 完全無法掌握這個函式的型別,也同樣無法避免去取到該物件中不存在的屬性。

我們可以用剛剛定義好的 user 物件來想想看要怎麼定義這個函式的型別。首先因為函式的參數 objkey 都需要被明確的給予型別,所以可以:

  1. 定義 User 型別
  2. obj 可以接受的型別是 User
  3. key 需要是 obj 中帶有的屬性 key,

寫起來會像這樣:

// STEP 1:定義 `User` 的型別
type User = {
firstName: string;
lastName: string;
age: number;
isAdmin: boolean;
};

// STEP 2:使用 `User` 的型別來定義 `obj` 和 `key` 的型別
const getObjValue = (obj: User, key: 'firstName' | 'lastName' | 'age' | 'isAdmin') =>
obj[key];

你會注意到,在 key 的型別中,我們列出了所有 User 這個物件型別可能有的屬性名稱('firstName' | 'lastName' | 'age' | 'isAdmin'),稍微思考一下前幾週所學的內容,應該會發現可以用 keyof 來取代這樣的寫法,否則未來如果 user 的物件有添加新的屬性時,就還需要去修改 key 的型別定義,非常不方便,也不符合 single source of truth 的原則。

因此可以把函式改成這樣:

keyof operator

是不是精簡了不少?

這時候就可以確保使用 getObjValue 的開發者不會發生想要在 user 物件中去取得 title 屬性的情況,因為 TypeScript 會知道 title 這個屬性並不存在 User 中,它會直接報錯:

typescript

試著使用泛型

這樣做雖然可以避免開發者誤用不存在 User 型別中的屬性,但因為我們指定了 obj 的型別是 User,進而導致這個函式變成只能針對 User 型別才能使用,如果 obj 不是 User 的話,就完全沒辦法再使用 getObjValue

例如,現在想要取出的是 product 物件中 name 屬性的 value,但因為 product 物件不滿足先前定義的 User 型別,所以 TypeScript 會報錯:

const getObjValue = (obj: User, key: keyof User) => obj[key];

const product = {
name: 'iPad mini',
price: 14900,
manufacturer: 'Apple',
madeIn: 'China',
};

// product 並不滿足 User 的型別
getObjValue(product, 'name'); // ❌ TypeScript compile error

我們不會想要每當有不同的物件時,就寫一個新的、但功能完全一樣的函式,這樣 getObjValue 就太不好用了:

// 如果不使用泛型...
const getObjValueOfUser = (obj: User, key: keyof User) => obj[key];
const getObjValueOfProduct = (obj: Product, key: keyof Product) => obj[key];

這時候你是否有回憶起前幾天提到「泛型」這個好用的東東,讓我們試著把上面共同的部分抽出來,變成一個泛型的變數:

TypeScript generics

可以看到,UserProduct 就是可以被抽出來變成泛型變數的部分,變成這樣:

function getObjValue<T>(obj: T, key: keyof T) {
return obj[key];
}

如此,這個函式就可以同時帶入任何的物件而不需要重複定義函式,如果使用者用了物件中不存在的屬性時 TypeScript 一樣會提出警告:

const user = {
firstName: 'PJ',
lastName: 'Chen',
age: 35,
isAdmin: true,
};

const product = {
name: 'iPad mini',
price: 14900,
manufacturer: 'Apple',
madeIn: 'China',
};

function getObjValue<T>(obj: T, key: keyof T) {
return obj[key];
}

const isAdmin = getObjValue(user, 'isAdmin'); // true
const manufacturer = getObjValue(product, 'manufacturer'); // 'Apple'

如同在第二天針對泛型所提到的,如果沒有在 <> 中指定泛型參數的型別,TypeScript 會自動根據帶入函式的參數來推導泛型的型別(type argument inference),這就是為什麼這裡可以不用寫成 getObjValue<User>(...) 這種明確告知泛型型別的方式。

如果有需要,可以限制泛型

上面 getObjValue 一般來說使用上已經沒有什麼問題了,但如果我們希望把 key 的型別也變成一個泛型的參數,讓使用這個函式的開發者可以自己決定要帶入的 key 型別是什麼時(例如,只能取出該物件中的部分屬性),可以怎麼做呢?

這時候第一步就是把 key 的型別,也變成一個泛型的參數,這裡稱作 U,讓使用者有自行決定 key 的型別(U)的機會:

function getObjValue<T, U>(obj: T, key: U) {
return obj[key];
}

但這時候 TypeScript 會報錯:

generic constraints

這個錯誤的意思是說,TypeScript 沒辦法確認 U 一定是 T 的 index,換成比較好理解的方式就是,因為 U 太泛了,沒辦法保證物件 T 中有 U 這個 key 存在。為了要解決這個問題,可以使用在 Day03 學到的泛型限制,透過 extends 來確保 U 一定是物件型別 T 裡存在的 key:

generic constraints

就像這樣,使用了 U extends keyof T 的方式,來確保泛型 U 一定滿足物件型別 T 的 key。

如果我們把滑鼠移到 getObjValue 這個函式時,留意一下它寫的這個函式會回傳的型別,你會發現它寫的是 T[U],而這不就是我們昨天提到的 indexed access types 嗎?T 是物件型別,U 是物件的 key,T[U] 就是該屬性值的型別:

indexed access types

最後,使用者如有需要可以自行指定泛型的型別,並限制 getObjValue 能夠取用的 key 的型別:

type Product = {
name: string;
price: number;
manufacturer: string;
madeIn: string;
};

// 限制這裡的 getObjValue 只能取用物件中的 'manufacturer' | 'price' 這兩個屬性
const age = getObjValue<Product, 'manufacturer' | 'price'>(product, 'manufacturer');

如果在定義 key 的型別時,不小心寫了原本就不存在物件中的屬性時,TypeScript 一樣會提早告知錯誤。例如這裡,試著在定義 key 的型別時,取用了不存在 Product 型別中的屬性 key age,TS 就會跳出錯誤:

generic constraints

這個簡單的 getObjValue 函式,就用了多個前幾天提到的知識點,如果有對那個部分感到不熟悉,都可以翻閱前幾天的內容對照著看。

Generic Function 可以作為 Type 使用

Instantiation Expressions @ microsoft devblogs

在 TypeScript 4.7 以前,TypeScript 是根據 generic function 被帶入的參數來自動推導該函式參數的型別,但有些時候我們希望能夠直接指定這個 Generic Function 帶入的泛型的型別是什麼,在 TypeScript 4.7 後,可以直接把 generic function 帶入型別,它會回傳另一個已經「限制特定型別」的 function,非常方便:

// Instantiation Expressions

function greet<T extends { name: string }>(value: T) {
return `Hello ${value.name}`
}

type People = { name: string; age: number };
type Pet = { name: string; breed: string }

// 可以直接把型別帶入
const greetPeople = greet<People>;
const greetPet = greet<Pet>;

泛型函式使用小訣竅

  • 先用實際的型別來達成你想要的功能,在將可以共用的部分抽成泛型的變數
  • 除非函式的「參數間及回傳值間」有所關聯,否則你可能不需要用到泛型
  • 除非有需要,否則能用越少的泛型參數來達成想要的功能越好
  • 如果泛型太泛而導致 TS 報錯時,可以使用 generic constrains 提供的 extends 來限制泛型,但如果能在不限制的形況下就完成想要的功能,就不要給予多餘的限制

範例程式碼

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

參考資料