Type level 的 extends 不是 inheritance。它是一個問題:T 能否 assignable to U?Ternary 選一個 type 或另一個。infer 幫對上的那一塊取名。
這篇 note 依 Kyle 的 walkthrough。Class extends —— prototype chain、super、instanceof —— 見 TypeScript Class 與 Runtime Identity。這篇是 type-level 的那一半:constraints、conditionals,以及你從別的 types 抽出來的 types。
1. 四種 extends
同一個字做四件事。只有 class 那一種活在 runtime。
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, C。typealias 不能extend。Intersection(&)是同一種 composition:Named & { age: number }。 - Generic constraint
T extends U是一道 gate。T必須 assignable toU,否則 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。
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 stringType system 沒有 if block。Nested ternaries 是唯一的 if/else:
type Kind<T> = T extends string
? "string"
: T extends number
? "number"
: "other"extends 左邊一個 naked type parameter 會對 unions distribute。這就是 Exclude 與 Extract 存在的原因:
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> // stringExclude<string | null, null> 是兩次 check:string extends null(留下 string)與 null extends null(掉成 never)。string | never 就是 string。兩邊都包進 tuple,就能 停掉 distribution:
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:Exclude、Extract、NonNullable。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;幫那個洞取名。
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> // neverFalse branch 回傳 T 是另一種常見形狀 —— 對不上就保留 input。那是下面的 Unwrap。ReturnType 是對 return slot 做 infer:
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> // ResponseBuiltin Awaited 會 recurse,直到沒有 Promise。One-liner 是那個 idea。對 inferred 的那一塊再做一次 ternary,仍然只是 ternary:如果 array element extends string,就回傳別的東西。
Production default 是 standard library:ReturnType、Parameters、Awaited、ConstructorParameters、InstanceType。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:
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"> // neverVideo 裡的 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 變成另一個的方式。
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(或 +)是加:
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 的 Partial、Required、Readonly 就是這些 one-liners。
Key remapping 用 as。Capitalize 是 builtin string helper。keyof T 可以是 string | number | symbol,所以新 key 是 template 時要跟 string intersect:
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(Partial、Readonly、Pick、Omit),除非 public type 本身就是一次 transformation —— getters、event map、on${Capitalize<P>} handlers。
Failure: 親手重寫 Partial 或 Readonly。外面的名字才是變了的東西時,才 remap keys。
Takeaway
extends 是一次 check。Ternary 是一次 branch。infer 是一個有名字的洞。Mapped types 是 loop;as 是 rename。
當問題是要不要寫一個時:
- 這是 constraint(parameter 上的
T extends U)、branch(T extends U ? X : Y),還是 extract(infer)? - Standard library 是否已經取過名?
ReturnType、Awaited、Exclude、Partial。 - 還是這是 library 正在發明的 type? Route string、i18n param、builder。那才寫。