typescript 的 conditional types

2022-04-30

最近在開發一個簡化使用 useReducer 流程的 package,雖然實作起來不難,但是想要讓它能夠有很好的 type definition 卻不是一件簡單的事。

那麼,為什麼一定要有良好的 type definition 呢?就算沒有,這個 package 不是也能 work 嗎?確實,即便沒有 type definition,這個 package 也能夠正常使用。然而,我認為身為一個 package developer,除了提供 package 的功能以外,也應該要提供良好的開發者體驗。

什麼是 conditional types

熟悉 javascript 的讀者應該對於 conditional expression 不陌生:

condition ? trueExpression : falseExpression;

簡單來說就是當 condition 成立時,我們會得到 trueExpression 的值,而當 condition 沒有成立時,我們會得到 falseExpression 的值。

typescript 的 conditional types 其實也是一樣的,我們來看看官方給的例子:

interface Animal {
  live(): void;
}
interface Dog extends Animal {
  woof(): void;
}
type Example1 = Dog extends Animal ? number : string;
type Example2 = RegExp extends Animal ? number : string;

在上面的例子中,因為 DogAnimal 的 extension,所以 Example1 的 type 會是 number。而 RegExp 不是 Animal 的 extension,所以 Example2 的 type 會是 string

conditional types 通常會搭配著 generics 使用:

interface IdLabel {
  id: number /* some fields */;
}
interface NameLabel {
  name: string /* other fields */;
}
type NameOrId<T extends number | string> = T extends number ? IdLabel : NameLabel;

function createLabel<T extends number | string>(idOrName: T): NameOrId<T> {
  throw 'unimplemented';
}

const a = createLabel('typescript');
const b = createLabel(2.8);
const c = createLabel(Math.random() ? 'hello' : 42);

當傳入 string 進去 createLabel 時,createLabel 的回傳值的 type 會是 NameOrId<string>,也就是 NameLabel。以此類推,當傳入值的 type 為 number 時,就會得到 type 為 IdLabel 的回傳值。而當傳入值的 type 為 number | string 時,則會得到 type 為 NameLabel | IdLabel 的回傳值。

在 conditional types 中使用 infer

除了 conditional types 以外, typescript 還提供了一個好用的 keyword 叫做 inferinfer 可以讓我們指出在 condition 中的哪個部分可以用在 trueExpression 中:

type ReturnType<F> = F extends (...args: any[]) => infer R ? R : never;

在上面的例子中,如果 ReturnType<F>F 是一個 function 的話,它所代表的 type 就是 F 的回傳值。

結語

其實原本是想要分享開發 use-case-reducers 時,在 type definition 上面遇到的問題,順便解釋什麼是 conditional types。但後來發現這樣子文章好像會變得很長,而且 use-case-reducers 也還不算完全開發完,所以就先把 conditional types 拿出來講,之後應該還會再發一篇 use-case-reducers 的開發心得。