iT邦幫忙

2021 iThome 鐵人賽

DAY 6
1
Modern Web

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

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

  • 分享至 

  • xImage
  •  

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

  • 泛型(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 物件來想想看要怎麼定義這個函式的型別。首先因為函式的參數 obj 和 key 都需要被明確的給予型別,所以可以:

  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

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

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 函式,就用了多個前幾天提到的知識點,如果有對那個部分感到不熟悉,都可以翻閱前幾天的內容對照著看。

泛型函式使用小訣竅

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

範例程式碼

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

參考資料


上一篇
[Day05] TS:如何把物件型別的所有屬性值取出變成 union type?試試 Indexed Access Types
下一篇
[Day07] TS:什麼是 Utility Types?
系列文
React 前端工程師的兩把刷子 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言