跳至主要內容
返回

TypeScript Infer、Extends 與 Ternaries

TypeScript

extends 是 assignable-to;ternary 選一個 type;infer 幫你對上的那一塊取名 —— 再加上 template literals 與 mapped types

Type level 的 extends 不是 inheritance。它是一個問題:T 能否 assignable to U?Ternary 選一個 type 或另一個。infer 幫對上的那一塊取名。

這篇 note 依 Kyle 的 walkthrough。Class extends —— prototype chain、superinstanceof —— 見 TypeScript Class 與 Runtime Identity。這篇是 type-level 的那一半:constraints、conditionals,以及你從別的 types 抽出來的 types。



1. 四種 extends

同一個字做四件事。只有 class 那一種活在 runtime。


ts
class Bird extends Animal {}

interface Named {
  name: string
}

interface Aged extends Named {
  age: number
}

type AgedAsType = Named & { age: number }

type NameOf<T extends { name: string }> = T["name"]

  • Class extends 接上 prototype chains。那是一個 value。另外三種會被 erased。
  • Interface extends 把 fields 複製進新的 shape。可以有多個 parents:interface B extends A, Ctype alias 不能 extend。Intersection(&)是同一種 composition:Named & { age: number }
  • Generic constraint T extends U 是一道 gateT 必須 assignable to U,否則 type argument 被拒。NameOf<number> 是 error。NameOf<{ name: "Ada"; extra: true }>"Ada"
  • Conditional T extends U ? X : Y 是一次 branch。任何 T 都接受;結果取決於 assignability。下一節就是這種形式。

T extends U 的意思是 assignable to U,不是「T 和 U」,也不是「T 比較大」。Object types 多出來的 fields 仍然 assign:{ name: string; age: number } assignable to { name: string }。Values 這邊,"hi" extends string —— value set 更小,不是更大。String literal 是 string 的 subtype。


Failure:T extends string 讀成 T & string。Intersection 是 &extends 是一次 check。


2. Ternaries

Constraint 會拒掉壞的 T。Conditional 接受任何 T,然後 branch


ts
type IsString<T> = T extends string ? true : false

type A = IsString<"hi"> // true
type B = IsString<number> // false
type C = IsString<string & { brand: "x" }> // true — still assignable to string

Type system 沒有 if block。Nested ternaries 是唯一的 if/else:


ts
type Kind<T> = T extends string
  ? "string"
  : T extends number
    ? "number"
    : "other"

extends 左邊一個 naked type parameter 會對 unions distribute。這就是 ExcludeExtract 存在的原因:


ts
type Exclude<T, U> = T extends U ? never : T
type Extract<T, U> = T extends U ? T : never

type WithoutNull = Exclude<string | null, null> // string
type OnlyString = Extract<string | number, string> // string

Exclude<string | null, null> 是兩次 check:string extends null(留下 string)與 null extends null(掉成 never)。string | never 就是 string。兩邊都包進 tuple,就能 停掉 distribution:


ts
type ToArray<T> = T extends any ? T[] : never
type Dist = ToArray<string | number> // string[] | number[]

type ToArrayTogether<T> = [T] extends [any] ? T[] : never
type Together = ToArrayTogether<string | number> // (string | number)[]

Production default 是 standard library:ExcludeExtractNonNullable。Library 在發明一個 type 時才 nest ternaries —— i18n params、router、Zod 的 output。


Failure: 一個其實就是 Exclude 的 nested ternary。Application code 裡手寫 T extends U ? never : T 是噪音。


3. infer

infer 只在 conditional 裡合法。它在 extends clause 宣告一個 type variable,在 true branch 使用。對一個 shape 做 pattern-match;幫那個洞取名。


ts
type Flatten<T> = T extends Array<infer Item> ? Item : never

type A = Flatten<string[]> // string
type B = Flatten<[1, 2, 3]> // 1 | 2 | 3
type C = Flatten<string> // never

False branch 回傳 T 是另一種常見形狀 —— 對不上就保留 input。那是下面的 UnwrapReturnType 是對 return slot 做 infer


ts
type ReturnType<T extends (...args: never[]) => unknown> = T extends (
  ...args: never[]
) => infer R
  ? R
  : never

type Unwrap<T> = T extends Promise<infer U> ? U : T

type FetchReturn = ReturnType<typeof fetch> // Promise<Response>
type FetchValue = Unwrap<FetchReturn> // Response

Builtin Awaited 會 recurse,直到沒有 Promise。One-liner 是那個 idea。對 inferred 的那一塊再做一次 ternary,仍然只是 ternary:如果 array element extends string,就回傳別的東西。


Production default 是 standard library:ReturnTypeParametersAwaitedConstructorParametersInstanceType。Shape 是你自己的時候才用 infer —— tagged template、route string、builder 累積出來的 type。


Failure: Application code 裡手寫一份 ReturnType。Stdlib 已經取過名。


4. Template literals

Template literal type 是帶洞的 string type。配上 infer,它拆 string 的方式就像 regex 抓 groups:


ts
type Split<T extends string> = T extends `${infer K}:${infer V}`
  ? { key: K; value: V }
  : never

type A = Split<"name:Kyle"> // { key: "name"; value: "Kyle" }
type B = Split<"no-colon"> // never

Video 裡的 i18n {param} extractor 就是這個 nested:對上 prefix、一個 {...} capture、以及 rest;抽出 capture;對 rest recurse。還是那三個 keywords。沒有第四個。


5. Mapped types

Mapped type 是對 keys 的 loop。{ [P in keyof T]: ... } 重建一個 object type。Value 裡的 ternary,或 key 上的 as,就是一個 object type 變成另一個的方式。


ts
type O = { name: string; age: number }

type StringsToNumbers<T> = {
  [P in keyof T]: T[P] extends string ? number : T[P]
}

type N = StringsToNumbers<O> // { name: number; age: number }

Modifiers 加上或拿掉 readonly 與 optionality。- 是剝;bare keyword(或 +)是加:


ts
type Mutable<T> = { -readonly [P in keyof T]: T[P] }
type Partial<T> = { [P in keyof T]?: T[P] }
type Required<T> = { [P in keyof T]-?: T[P] }
type Readonly<T> = { readonly [P in keyof T]: T[P] }

Standard library 的 PartialRequiredReadonly 就是這些 one-liners。

Key remapping 用 asCapitalize 是 builtin string helper。keyof T 可以是 string | number | symbol,所以新 key 是 template 時要跟 string intersect:


ts
type Getters<T extends Record<string, unknown>> = {
  [P in keyof T & string as `get${Capitalize<P>}`]: () => T[P]
}

type G = Getters<{ name: string; age: number }>
// { getName: () => string; getAge: () => number }

Production default 是 stdlib(PartialReadonlyPickOmit),除非 public type 本身就是一次 transformation —— getters、event map、on${Capitalize<P>} handlers。


Failure: 親手重寫 PartialReadonly。外面的名字才是變了的東西時,才 remap keys。


Takeaway

extends 是一次 check。Ternary 是一次 branch。infer 是一個有名字的洞。Mapped types 是 loop;as 是 rename。

當問題是要不要寫一個時:

  1. 這是 constraint(parameter 上的 T extends U)、branchT extends U ? X : Y),還是 extractinfer)?
  2. Standard library 是否已經取過名? ReturnTypeAwaitedExcludePartial
  3. 還是這是 library 正在發明的 type? Route string、i18n param、builder。那才寫。

Recap Q&A