TypeScript patterns I actually use, and one that cost meβ¦
Typed translation keys, satisfies, discriminated state and branded ids β plus the conditional type that took my typecheck from 14 seconds to 170 and how Iβ¦
Most "advanced TypeScript" writing is a tour of type-level tricks that nobody should ship. These are the four patterns I reach for weekly across a portfolio of React and Capacitor products, and one lesson about what happens when a clever type meets a real codebase.
I will start with the lesson, because it is the one that cost me a morning.
A conditional type over a big union will destroy your typecheck
I was building the translation layer for this site. The requirement is simple and correct: a missing translation key must be a compile error, not a blank space on a page. So the key type is derived from the English catalogue.
Plural families were stored with suffixes β catalogue.count_one, catalogue.count_other β and callers should pass catalogue.count. So I wrote the obvious thing: a conditional type that strips the suffix.
// Do not do this over 1,100 keys.
type StripPlural<K> = K extends `${infer B}_one` | `${infer B}_other` ? B : K;
type TranslationKey = StripPlural<keyof typeof en>;
It compiled. It was correct. And tsc -b went from fourteen seconds to a hundred and seventy.
Three minutes is not slow, it is fatal β a typecheck that takes three minutes is a typecheck people stop running, and mine runs before every build. So I measured rather than guessed:
tsc -b --extendedDiagnostics
The tell was Instantiations: 701,619. Then three variants, timed:
- the distributive conditional β 169.9 s
string, meaning no safety at all β 62.2 s
- a plain
keyof union β 14.3 s
The cause is that a conditional type does not normalise into the fast literal-union lookup TypeScript uses for a plain keyof. Every one of roughly fifteen hundred call sites re-derived it.
The fix was not a cleverer type, it was a better data shape. Plurals moved into their own object, and the key type became two plain keyofs:
// { "strings": { ... }, "plurals": { "catalogue.count": { one, other } } }
export type TranslationKey = keyof typeof en.strings | keyof typeof en.plurals;
Same guarantee, no conditional, fourteen seconds. And it is the better catalogue anyway β a translator sees a plural family as one object rather than as a suffix convention they have to know about.
If your typecheck suddenly gets slow, suspect a conditional or recursive type over a large union before you suspect anything else.
satisfies, for when you want both
The pattern I use most, and the one that took longest to become instinct. as const preserves literal types but gives you no checking. A type annotation gives you checking but widens everything. satisfies gives you both.
const NAV = [
{ labelKey: 'nav.site.about', to: '/about' },
{ labelKey: 'nav.site.blog', to: '/blog' },
] as const satisfies readonly { labelKey: TranslationKey; to: string }[];
A typo in a label key fails here, at the declaration, naming the line. Annotate it as the interface instead and to widens to string, which quietly disables the router's own link checking downstream β you get one error where you wanted, and lose fifty you did not know you had.
Discriminated unions instead of boolean soup
Four booleans describe sixteen states, of which four are real. I have debugged the other twelve.
type Gate =
| { state: 'loading' }
| { state: 'anonymous' }
| { state: 'forbidden'; role: Role }
| { state: 'admin'; role: 'admin' | 'superadmin' };
The payoff is exhaustiveness. Add a fifth state and every switch that handles the union fails to compile until it is handled. With booleans you add a fifth case and every existing branch silently falls through to whichever condition happens to be checked last β usually the permissive one.
That is not a style preference on an auth gate. The permissive branch is the dangerous one.
Branded ids, sparingly
Every id in a database is a string, so nothing stops you passing a user id where a post id belongs.
type Brand<T, B> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type PostId = Brand<string, 'PostId'>;
I use this where two ids of the same shape flow through the same functions and swapping them would be a data-correctness bug rather than a crash. I do not use it everywhere, because each brand needs a cast at the boundary where a real string becomes one, and a codebase full of casts has traded a type error for a ceremony.
Never turn a null into a fallback you invented
Less a type pattern than a discipline the types keep pushing me toward. When a value is nullable, the question to ask is whether it can genuinely be null β and if it cannot, fix the schema rather than writing ?? 1.
I hit this today. A generated column computes a post's read time; the expression cannot return null, but I had not declared the column not null, so the generated types said number | null. That nullability would have travelled to every call site and become a fallback that looks like careful handling and is really a made-up number for a case that cannot happen.
One line of SQL removed it everywhere. That is usually the shape of it: a nullable type in the application is often a missing constraint in the database.
Make the impossible state unrepresentable, then let the compiler check exhaustively
The pattern underneath all four above, and the one worth internalising: the useful question is not "how do I type this value" but "how do I stop the wrong shape existing".
A concrete example from this codebase. A route's authorization gate needs to distinguish loading, signed out, signed in without permission, and signed in with permission. Modelled as booleans β isLoading, isSignedIn, isAdmin β there are eight combinations and four of them are nonsense, including "not loading, not signed in, but is an admin". Nothing prevents that state, so every consumer defends against it by checking the flags in a particular order, and the order is the real specification while being written nowhere.
As a union, the nonsense states cannot be constructed. And the payoff arrives later, when a fifth state appears: every place that handles the union stops compiling until it handles the new one.
const label = (gate: Gate): string => {
switch (gate.state) {
case 'loading': return 'Checkingβ¦';
case 'anonymous': return 'Sign in';
case 'forbidden': return 'Not available on your account';
case 'admin': return 'Open the panel';
default: {
const exhaustive: never = gate; // a new state fails HERE
return exhaustive;
}
}
};
The never assignment is what turns a silent fall-through into a compile error. Without it, adding a state means every switch quietly takes its default branch β and on an authorization gate the default branch is either broken or permissive, and permissive is the one that does not get reported.
What I left out
Template literal types beyond simple cases, deep recursive utilities, and anything requiring a comment to explain why it compiles. They are genuinely impressive and they are a tax on the next person, who is frequently me in eight months.
The patterns above share one property: each catches a real defect I have shipped, and none of them needs explaining twice.
https://aoneahsan.com/blog/advanced-typescript-patterns-2026