extends at the type level is not inheritance. It is a question: is T assignable to U? A ternary picks one type or the other. infer names the piece that matched.
This note follows Kyle's walkthrough. Class extends — the prototype chain, super, instanceof — is TypeScript Classes and Runtime Identity. This note is the type-level half: constraints, conditionals, and the types you extract from other types.
1. Four extends
The same word does four jobs. Only the class form exists at 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
extendswires prototype chains. That is a value. The other three are erased. - Interface
extendscopies fields into a new shape. Multiple parents are allowed:interface B extends A, C. Atypealias cannotextend. Intersection (&) is the same composition:Named & { age: number }. - A generic constraint
T extends Uis a gate.Tmust be assignable toUor the type argument is rejected.NameOf<number>is an error.NameOf<{ name: "Ada"; extra: true }>is"Ada". - A conditional
T extends U ? X : Yis a branch. AnyTis accepted; the result depends on assignability. That form is the next section.
T extends U means assignable to U, not "T and U" and not "T is bigger." For object types, extra fields still assign: { name: string; age: number } is assignable to { name: string }. For values, "hi" extends string — the set of values is smaller, not larger. A string literal is a subtype of string.
Failure: reading T extends string as T & string. Intersection is &. extends is a check.
2. Ternaries
A constraint rejects a bad T. A conditional accepts any T and branches.
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 stringThe type system has no if block. Nested ternaries are the only if/else:
type Kind<T> = T extends string
? "string"
: T extends number
? "number"
: "other"A naked type parameter on the left of extends distributes over unions. That is why Exclude and Extract exist:
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> is two checks: string extends null (keep string) and null extends null (drop to never). string | never is string. Wrap both sides in a tuple to stop 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)[]The production default is the standard library: Exclude, Extract, NonNullable. Nest ternaries when the library is inventing a type — i18n params, a router, Zod's output.
Failure: a nested ternary that is Exclude. Hand-rolling T extends U ? never : T in application code is noise.
3. infer
infer is only legal inside a conditional. It declares a type variable in the extends clause and uses it in the true branch. Pattern-match a shape; name the hole.
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> // neverReturning T on the false branch is the other common shape — keep the input when it does not match. That is Unwrap below. ReturnType infers the return slot instead:
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> // ResponseThe builtin Awaited recurses until there is no Promise left. The one-liner is the idea. A second ternary on the inferred piece is still just a ternary: if the array element extends string, return something else.
The production default is the standard library: ReturnType, Parameters, Awaited, ConstructorParameters, InstanceType. Reach for infer when the shape is yours — a tagged template, a route string, a builder's accumulated type.
Failure: a hand-rolled ReturnType in app code. The stdlib already named it.
4. Template literals
A template literal type is a string type with holes. Combined with infer, it splits a string the way a regex captures 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"> // neverThe i18n {param} extractor in the video is this nested: match a prefix, a {...} capture, and the rest; pull the capture; recurse on the rest. Same three keywords. No fourth.
5. Mapped types
A mapped type is a loop over keys. { [P in keyof T]: ... } rebuilds an object type. A ternary inside the value, or as on the key, is how one object type becomes another.
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 add or remove readonly and optionality. - strips; a bare keyword (or +) adds:
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] }Partial, Required, and Readonly in the standard library are these one-liners.
Key remapping uses as. Capitalize is a builtin string helper. keyof T can be string | number | symbol, so intersect with string when the new key is a template:
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 }The production default is the stdlib (Partial, Readonly, Pick, Omit) unless the public type is a transformation — getters, an event map, on${Capitalize<P>} handlers.
Failure: rewriting Partial or Readonly by hand. Remap keys when the name on the outside is the thing that changed.
Takeaway
extends is a check. A ternary is a branch. infer is a named hole. Mapped types are the loop; as is the rename.
When the question is whether to write one:
- Is this a constraint (
T extends Uon the parameter), a branch (T extends U ? X : Y), or an extract (infer)? - Does the standard library already name it?
ReturnType,Awaited,Exclude,Partial. - Or is this a type the library is inventing? A route string, an i18n param, a builder. Then write it.