TypeScript's built-in utility types — Partial, Pick, Readonly — aren't compiler magic. They're written in ordinary TypeScript, using two features you can use yourself: conditional types and mapped types. Once you understand how Partial<T> is actually implemented, writing your own utility types for the shapes specific to your codebase stops feeling like arcane wizardry.
Conditional types: type-level if statements
A conditional type picks between two types based on whether one type is assignable to another:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // falseThis becomes genuinely useful with infer, which lets you pull a type out of a larger structure instead of just testing it:
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
type C = UnwrapPromise<Promise<number>>; // number
type D = UnwrapPromise<string>; // stringinfer U says "whatever type fills this position, capture it as U and give it back to me." It's how Awaited<T> is built under the hood, and it's the same trick you'd use to extract a function's return type or an array's element type.
Distribution over unions
Conditional types distribute automatically over unions, which trips people up the first time they see it:
type ToArray<T> = T extends unknown ? T[] : never;
type Result = ToArray<string | number>; // string[] | number[], not (string | number)[]Each member of the union is checked independently and the results are re-unioned. If you don't want that behavior, wrap both sides in a tuple: [T] extends [unknown] ? T[] : never disables distribution.
Mapped types: transforming every key at once
A mapped type walks the keys of an existing type and applies the same transformation to each one:
type MyPartial<T> = {
[K in keyof T]?: T[K];
};
type MyReadonly<T> = {
readonly [K in keyof T]: T[K];
};That's the entire implementation of Partial and Readonly. The [K in keyof T] syntax is the mapped-type equivalent of a for...in loop over the type's keys.
Combining both: a real utility type
A deep-readonly type — one that recursively locks down nested objects, not just the top level — needs both conditional and mapped types together:
type DeepReadonly<T> = T extends object
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
: T;
interface Config {
name: string;
server: { host: string; port: number };
}
type FrozenConfig = DeepReadonly<Config>;
// server.host is now readonly too, not just server itselfThe conditional type (T extends object ? ... : T) is the base case that stops recursion at primitives; the mapped type does the actual transformation at each level.
Key remapping, briefly
Since TypeScript 4.1, mapped types can rename keys as they map them, using as:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<{ name: string; age: number }>;
// { getName: () => string; getAge: () => number }This is where mapped types start overlapping with template literal types — worth knowing exists, but reach for it only when a codebase genuinely needs generated-shape types like this, not as a default way to define an interface.
Once these two features click, most "how do I express this type" problems in code review stop being blockers — you either extend an existing utility type or write a five-line one specific to the shape you actually have, instead of reaching for any because the built-ins don't quite fit.