Everything in the last lesson is built from four things: keyof, typeof,
mapped types and conditional types. This is the part of TypeScript that stops looking like
annotations and starts looking like a small language for computing types — which is exactly what it
is.
You will use the first half of this daily and the second half occasionally. Both are worth being able to read.
keyof
keyof T is the union of T's property names:
type ProductKey = keyof Product;
// 'id' | 'name' | 'description' | 'type' | 'imageUrl' | 'active'
// | 'displayOrder' | 'sizes' | 'createdAt' | 'updatedAt'Which is what makes a function like this safe:
function pluck<T, K extends keyof T>(item: T, key: K): T[K] {
return item[key];
}keyof on a type with an index signature gives you the index type instead —
keyof Record<string, string> is string | number, the
number being there because JavaScript coerces numeric keys to strings.
typeof, in type position
TypeScript reuses the typeof keyword for something quite different from the runtime
operator. In a type position it means "the type of this value":
const RANGES = [7, 30, 90] as const;
type Range = typeof RANGES; // readonly [7, 30, 90]This is the bridge from the value world to the type world, and it is the reason the enum replacement works at all. You write the values once, as a value, and derive the type from them.
Indexed access
T[K] is the type of a property, using the same bracket syntax as a value:
type Sizes = Product['sizes']; // ProductSize[]
type Url = Product['imageUrl']; // string | null
type Either = Product['id' | 'name']; // UUID | stringTwo special keys do a lot of work. [number] gives the element type of an array:
type Size = Product['sizes'][number]; // ProductSize
type Days = (typeof RANGES)[number]; // 7 | 30 | 90and [keyof T] gives the union of all the value types — which is the expression the
last two lessons kept promising to explain:
export const ORDER_STATUS = {
PENDING_PAYMENT: 'PENDING_PAYMENT',
PAID: 'PAID',
} as const;
type OrderStatus = (typeof ORDER_STATUS)[keyof typeof ORDER_STATUS];Read it right to left. typeof ORDER_STATUS is the object's type.
keyof that is 'PENDING_PAYMENT' | 'PAID' — the keys. Indexing the object
type by that union gives the union of the corresponding values. Since as const
made each value a literal type, the result is 'PENDING_PAYMENT' | 'PAID' again — but
derived, so adding a key extends it automatically.
Mapped types
A mapped type builds a new object type by walking the keys of an old one:
type Stringified<T> = {
[K in keyof T]: string;
};Read [K in keyof T] as a loop. For each key of T, produce a property of
that name with the given type. Which is all Partial is:
type Partial<T> = {
[K in keyof T]?: T[K];
};The ? adds the optional modifier; T[K] keeps the original property
type. Twelve characters, and it is one of the most-used types in the language.
Readonly is the same shape with a different modifier, and Record maps
over a supplied key union rather than keyof T:
type Readonly<T> = { readonly [K in keyof T]: T[K] };
type Record<K extends keyof never, V> = { [P in K]: V };Adding and removing modifiers
Prefix a modifier with - to strip it. That is how Required works:
type Required<T> = {
[K in keyof T]-?: T[K];
};
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};And with that you can write the deep version the built-ins do not provide:
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};Mapped, conditional and recursive at once — and genuinely useful for a nested form's draft state.
Key remapping
Since TypeScript 4.1 the key itself can be transformed, with as:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type ProductGetters = Getters<Pick<Product, 'name' | 'active'>>;
// { getName: () => string; getActive: () => boolean }Mapping a key to never removes it, which is how you filter properties by type:
type StringKeys<T> = {
[K in keyof T as T[K] extends string ? K : never]: T[K];
};This is where the machinery starts earning its "clever" reputation. It is the right tool for a library API; in application code, reach for it rarely and comment it when you do.
Conditional types
A type-level ternary:
type IsArray<T> = T extends unknown[] ? true : false;
type A = IsArray<string[]>; // true
type B = IsArray<string>; // falseextends here means "is assignable to", the same relation as everywhere else.
Exclude is one line of it:
type Exclude<T, U> = T extends U ? never : T;Which only works because of distribution. When the checked type is a bare type parameter and the argument is a union, the conditional runs once per member and the results are unioned back together:
Exclude<'PAID' | 'CANCELLED', 'CANCELLED'>
// runs as:
// 'PAID' extends 'CANCELLED' ? never : 'PAID' -> 'PAID'
// 'CANCELLED' extends 'CANCELLED' ? never : 'CANCELLED' -> never
// then: 'PAID' | never -> 'PAID'The final step is never vanishing from a union, from
lesson 4. Every piece of this has now appeared
somewhere earlier in the track.
To stop distribution, wrap both sides in brackets — [T] extends [U] — which
is occasionally what you want and always worth a comment.
infer
infer declares a type variable inside a conditional, capturing part of the type
being matched:
type ReturnType<F> = F extends (...args: never[]) => infer R ? R : never;
type ElementOf<T> = T extends (infer E)[] ? E : never;
type Item = ElementOf<ProductSize[]>; // ProductSizeRead it as pattern matching. "If F looks like a function returning
something, call that something R and give it to me."
Awaited, Parameters and ReturnType are all
infer. So is any library type that pulls a piece out of a shape you passed it.
Template literal types
String literal types can be built from other types, using template-string syntax at the type level:
type Handler = `on${Capitalize<'select' | 'close'>}`;
// 'onSelect' | 'onClose'Note that it distributed: a union in the placeholder produces a union of results. Two placeholders produce every combination, which is powerful and is also how a careless definition produces a type with fifty thousand members and a slow build.
The realistic uses are narrower than the demos suggest. Typed event names, as above. Prefixed keys:
type Prefixed<T, P extends string> = {
[K in keyof T as `${P}${Capitalize<string & K>}`]: T[K];
};And parsing a route, which is the one you will meet in a router's types:
type Params<S extends string> =
S extends `${string}:${infer P}/${infer Rest}` ? P | Params<Rest>
: S extends `${string}:${infer P}` ? P
: never;
type OrderParams = Params<'/orders/:orderId/items/:itemId'>;
// 'orderId' | 'itemId'Recursive, conditional and inferring three things at once — and it is why a modern router can
tell you that params.ordreId is a typo. It is also exactly the kind of type you should
be glad someone else wrote.
string & K appears in two of those, and it is not decoration.
keyof T is string | number | symbol, and a template literal cannot
interpolate a symbol — the intersection narrows it to the string keys.
How much of this do you need?
Honestly: keyof, typeof and indexed access, constantly. They are how you
derive one type from another, and that is ordinary good practice rather than cleverness.
Mapped and conditional types you should be able to read, because the utility types are made of them and so is every library's type definitions. Writing your own comes up a few times a year in application code.
Two warnings, since this is where TypeScript codebases go wrong. Type-level code has no debugger, so a wrong answer is found by staring. And it is not free — deeply recursive conditional types are a real cause of slow builds and of the editor going quiet mid-file.
The test is the same as for generics: if you cannot say what relationship the type is preserving, write the simpler thing. A hand-written type that is slightly repetitive beats a derived one nobody on the team can modify.
Debugging a type
Since there is no debugger, three techniques do most of the work.
Assign it to a variable and hover. The editor will show you what a type resolved to, which is usually enough:
type Debug = Params<'/orders/:orderId'>;
// ^ hover thisWrap it in Prettify — the helper from
lesson 14 — when the hover shows you the
derivation rather than the result.
Force an error deliberately. Assigning to a type you know is wrong makes the compiler print what it actually has:
// This does not compile — on purpose. The error message names the real type.
const check: never = null as unknown as Params<'/orders/:orderId'>;Crude, and effective when a type is too large for a tooltip.
A note on build performance
Worth taking seriously, because it is the failure mode that arrives late and is hard to trace.
Type-level computation is real computation, done by tsc on every check.
Recursive conditional types are the usual culprit — a route parser over a long path, a deep
mapped type over a large interface, a template literal that multiplies two unions together. The
symptoms are a slow npm run typecheck, an editor that stops offering completions in one
file, or the compiler giving up outright with Type instantiation is excessively deep and
possibly infinite.
If you hit that, the fixes in order of preference: reduce the recursion depth, replace the derived
type with a hand-written one, or split the work so fewer files depend on the expensive type. It is
also why skipLibCheck is standard —
lesson 18 — since a dependency's clever types would
otherwise cost you on every build.
Next
Assertions, as const and satisfies — the
as const this lesson leaned on twice, and the operator that is usually a better idea
than as.