[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 函式,就用了多个前几天提到的知识点,如果有对那个部分感到不熟悉,都可以翻阅前几天的内容对照着看。

泛型函式使用小诀窍

  • 先用实际的型别来达成你想要的功能,在将可以共用的部分抽成泛型的变数
  • 除非函式的「参数间及回传值间」有所关联,否则你可能不需要用到泛型
  • 除非有需要,否则能用越少的泛型参数来达成想要的功能越好
  • 如果泛型太泛而导致 TS 报错时,可以使用 generic constrains 提供的 extends 来限制泛型,但如果能在不限制的形况下就完成想要的功能,就不要给予多余的限制

范例程序码

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

参考资料


<<:  D11-用 Swift 和公开资讯,打造投资理财的 Apps { 台股申购实作.4 - 用 Calendar 物件处理台湾的民国年}

>>:  [Android Studio 30天自我挑战] Toast浮动显示快显元件

被供应商和客户G爆时来一帖「资讯安全oo声明书」吧

资讯安全管理制度(ISMS)相关文件属於企业或机构内部资安治理运行标准与纪录,机密等级(控管标准)大...

Day 13 - [语料库模型] 01-TF-IDF与余弦相似性

TF-IDF(Term Frequency - Inverse Document Frequency...

[Day 22] Facial Recognition: Google FaceNet

Google在2015年时发表了一篇论文, 提出了FaceNet网路架构。 而其实在前面几天实作人脸...

Day 28-制作购物车之Redux 3

主要呈现实作成果 以下内容有参考教学影片,底下有附网址。 (内容包括我的不专业解说分析及在实作过程中...

#11 No-code 之旅 — 在 Next.js 专案中显示 Notion 的资料 ft. Notion SDK

哈罗!昨天使用 SWR 实作了一个小功能,让使用者可以列出某 Github user 的所有公开 r...