Building your own utility types, and when to stop
You now have every tool the type system offers — keyof, indexed access, mapped types, conditional
types, infer, template literals. This final lesson of the module puts them together to build real,
useful utility types from scratch, and then delivers the most important message of the whole module:
knowing when to stop. The measure of type-level skill is not how clever a type you can write; it
is writing the simplest type that solves the problem, and no more.
Rebuilding the standard library
The built-in utility types are not magic — they are combinations of the tools you have learned. Building a few yourself proves you understand them:
// Partial — mapped type adding ?
type MyPartial<T> = { [K in keyof T]?: T[K] };
// Required — mapped type removing ?
type MyRequired<T> = { [K in keyof T]-?: T[K] };
// Readonly — mapped type adding readonly
type MyReadonly<T> = { readonly [K in keyof T]: T[K] };
// Pick — mapped type over a subset of keys
type MyPick<T, K extends keyof T> = { [P in K]: T[P] };
// Exclude — distributive conditional
type MyExclude<T, U> = T extends U ? never : T;
// Omit — Pick over (all keys except the excluded ones)
type MyOmit<T, K extends keyof T> = MyPick<T, MyExclude<keyof T, K>>;
// NonNullable — conditional dropping null/undefined
type MyNonNullable<T> = T extends null | undefined ? never : T;
// Record — mapped type over a key union
type MyRecord<K extends string | number | symbol, V> = { [P in K]: V };
Read MyOmit: it picks from T the keys that are Exclude<keyof T, K> — all of T's keys except the
excluded ones. It is built from MyPick and MyExclude, which are themselves a mapped type and a
conditional type. This composition — small type-level tools combining into bigger ones — is the whole
game, and it is exactly how TypeScript's standard library is written. When you can build Omit from
Pick and Exclude, the utility types are no longer incantations; they are code you understand.
Genuinely useful custom utility types
Beyond rebuilding the standard ones, a few custom utilities are worth having, because the standard library does not include them and they solve real problems:
// DeepReadonly — readonly all the way down (Readonly is only shallow)
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
// DeepPartial — optional all the way down
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
// a type for "at least one of these keys is required"
type Nullable<T> = { [K in keyof T]: T[K] | null };
DeepReadonly<T> is a recursive mapped type: for each property, if the value is an object, recurse
(making it deeply readonly too); otherwise leave it. This solves the "Readonly is shallow" limitation
from the objects module — a genuinely useful type the standard library lacks. Building it uses a mapped
type, a conditional type, and recursion together — everything the module taught, in one place. These are
the kinds of custom utilities that earn their place: a real need the built-ins do not cover.
The crucial lesson: knowing when to stop
Here is the message that matters more than any technique. TypeScript's type system is powerful enough to compute almost anything — and that is a trap. It is possible to write types so clever that:
- Nobody else on the team can read them.
- The error messages they produce are pages of incomprehensible type expansions.
- The TypeScript compiler slows to a crawl (complex types have real compile-time cost).
- You spend an afternoon perfecting a type that a five-line runtime check would have handled.
A codebase full of baroque conditional-and-mapped-type machinery is worse, not better, than one with
straightforward types and a few honest anys at the genuinely dynamic edges. The type system is a tool
for catching bugs and documenting intent — the moment a type stops serving those goals and becomes an
end in itself, it has failed, however impressive it is.
The guidance, which is the design-patterns module's "when not to pattern" applied to types:
- Reach for the built-in utility types first —
Partial,Pick,Omit,Record,ReturnType,Awaited. They cover the vast majority of real needs, are readable, and everyone knows them. - Write a simple custom mapped or conditional type when the built-ins do not fit and the need is
clear (a
DeepReadonly, a domain-specific transform). - Stop before the type becomes unreadable. If you cannot explain a type in one sentence, or its
error messages are incomprehensible, it is too clever — simplify it, or solve the problem another way
(a runtime check, a simpler shape, an honest
anywith a comment). - Never write type-level machinery to show off. The goal is code your team can maintain, not a proof that you can bend the compiler to your will.
The most senior TypeScript developers write remarkably plain types most of the time, reaching for the advanced tools only where a real problem demands them — and when they do, they keep those types small, named, and documented. Mastery is restraint. You have learned the deep end of the type system so that you can use it when needed and, just as importantly, recognise it in libraries you use — not so that you use it everywhere.
Check your work
Are the built-in utility types magic? No — they are combinations of mapped types, conditional types,
keyof, and indexed access, which you can build yourself.
How Omit is built. Pick<T, Exclude<keyof T, K>> — pick all keys except the excluded ones — from
Pick and Exclude.
A genuinely useful custom utility. DeepReadonly<T> — a recursive mapped type making a type readonly
all the way down, which the shallow built-in Readonly cannot.
The trap of the type system's power. It can compute almost anything, tempting you into types nobody can read, with incomprehensible errors, slow compiles, and effort that a runtime check would have avoided.
The four-point guidance. Built-ins first; simple custom types when they do not fit; stop before unreadable; never for show.
The sign a type is too clever. You cannot explain it in one sentence, or its error messages are incomprehensible.
What mastery looks like. Plain types most of the time; advanced tools only where a real problem demands them, kept small and documented — restraint.
Practice
- Build
MyPartial,MyReadonly,MyPick, andMyExcludeyourself and confirm each matches the built-in. - Build
MyOmitfromMyPickandMyExclude. Confirm it removes the named keys. - Build a recursive
DeepReadonly<T>and confirm a nested property cannot be reassigned. - Build
DeepPartial<T>and confirm nested properties become optional. - Take a genuinely clever type (from a library, or one you write) and try to explain it in one sentence. If you cannot, consider whether it is too clever.
- Find a problem you solved (or could solve) with a complex type, and solve it instead with a runtime check or a simpler shape. Compare.
- Write down the one-sentence rule for when to stop reaching for advanced types, and keep it beside you.
Official documentation
- TypeScript — Utility Types — The built-ins, whose definitions you can now read and rebuild.
- TypeScript — Creating Types from Types — The overview of every tool this module covered.
- TypeScript — Performance — Why over-clever types slow the compiler, and how to keep types cheap.
Next module — Asynchronous TypeScript: typing the async code most real TypeScript is.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship