使用 TypeScript 的 generic type 實現可被覆寫的預設屬性

2023-03-10

需求

  1. 建立一個 generic type,用作 modify 輸入的 generic object
  2. 當傳入的 generic object 沒有傳入 myId 這個 key 時,就會加上預設的 property,如 { myId: string }
  3. 當傳入的 generic object 含有 myId 時,則會覆寫成傳入的 property type

例子:

/**
 * 當輸入的 object 沒有傳入 myId 這個 key 時,
 * 就會加上預設的 property { myId: string }
 */
type peopleDefaultId = People<{ name: string }>
/* expected:
{
    myId: string // default
    name: string
}
*/

/**
 * 當輸入的 object 含有 `myId` 時,
 * 則會覆寫成傳入的 property type
 */
type peopleNumberId = People<{ myId: number, name: string }>
/* expected:
{
    myId: number // override
    name: string
}
*/

/**
 * 代表一個 People 的 generic type,當輸入的 object 含有 `myId` key 時,
 * 會將 `myId` property 的型別覆蓋為傳入的型別,否則將加上預設的 property `{ myId: string }`,
 * 同時 `myId` key 的型別會保留為原本的 optional / non-optional 狀態。
 */
type peopleOptionalStringId = People<{ myId?: string, name: string }>
/* expected:
{
    myId?: string // override
    name: string
}
*/

TL;DR 解法

type ExtendsDefault<T, Default> = T & Partial<Pick<Default, Exclude<keyof Default, keyof T>>>;

type People<T> = ExtendsDefault<T, {
  myId: string
}>

踩坑過程

使用 | 符號

// 錯誤示範
type People<T> = {
  myId: string
} | T

如果輸入的 object 本身已經包含 myId key 的話(如 myId: number), 那輸出的 myId type 最後就會變成 string | number

type peopleNumberId = People<{ myId: number, name: string }>

// 預期:
// {
//   myId: number,
//   name: string
// }
// 實際輸出:
// {
//   myId: string | number,  // 錯誤!
//   name: string
// }

使用 extends 來斷使用 type

interface People<T> {
  myId: T extends { myId: infer U } ? U : string
}

先檢查輸入的 generic object 是否有 myId,有就把對應的 type 抽出來,沒有就直接 default 成 string

這樣看起來好像解決了第 1 個踩坑過程的問題,但是仔細看,由於上面的 myId 不是一個 optional 的 key,所以假設我今天輸入的 generic type 是 myId?: string 這種 optional property 時,我也必須要一定要加上 myId 這個 key,這樣並不方便。

// [error] Property 'myId' is missing in type '{}' but required in type
const people: People<{ myId?: string }> = {}
                                          ^^ // error

反過來,假如今天我預設的 myId 如果是 optional 的話,那當我 define 一個 non-null property,他將會強制我的 property 變成 optional。

interface People<T> {
  myId?: T extends { myId: infer U } ? U : string
}

const people: People<{ myId: string }> = {}
// output: { myId?: string } <- error: expected to be { myId: string }

加上判斷 key 是否 optional 的條件

type People<T extends { myId?: any }> = 'myId' extends keyof T
    ? T extends { myId: infer U }? { myId: U }
    : T extends { myId?: infer U } ? { myId?: U } : never
  : { myId: string };

這樣其實能滿足所有條件的,但感覺代碼有點又長又亂,然後後來發現可以用 [myId in keyof T] 直接拿到 myId 是否為 optional property 並拿來做 key,因此後來改善成了以下版本:

type People<T extends { myId?: any }> = 'myId' extends keyof T
  ? { [myId in keyof T]: T[myId] }
  : { myId: string };

然後順便做了一個可以輸入自定 key 的版本,這樣之後如果 property 不是 myId 的話就可以更方便使用了。

type DefaultProperty<T extends { [K in keyof T]?: any }, K extends keyof any> = K extends keyof T
  ? { [K in keyof T]: T[K] }
  : { [K in keyof any]: number };

// usage
type People<T, K> = DefaultProperty<T, 'myId'>

但做到這邊才發現問題就是,假設我想日後用這個 function 來 define 不同類型的預設 type,甚至是一次 default 更多的 properties,這樣其實也是很不方便的。

最後回到了原點,重新構想了一下,想像如果可以在 define generic type 時直接把 default 的 object 也以 generic 的方式傳進去,概念像是這樣。

type ExtendsDefault<T, Default> = ... ;

type People<T> = ExtendsDefault<T, {
  myId: string
}>

那就可以大大的提升可重用性和支援多個 default properties 了。

最終解法

在翻了好一陣的 TypeScript 文檔後,發現了 Pick 混用 Exclude 可以將一個 object 的 properties「取代」另一個 object 的 properties:

  • Pick 用來從 Default 中選擇需要的 key。
  • Exclude 用來從 Default 中排除已經存在於 T 中的 key。
  • Partial 用來將 Default 中剩下的 key 轉換為 optional。
type ExtendsDefault<T, Default> = T & Partial<Pick<Default, Exclude<keyof Default, keyof T>>>;

type People<T> = ExtendsDefault<T, {
  myId: string
}>

🎉  感謝您的閱讀,不仿看看我的其他文章,也歡迎在 XGitHub 上交流。