Why TypeScript Still Has a Skill Ceiling
Most TypeScript code in the wild looks the same. You get some interfaces, a handful of generics, maybe a utility type or two. The type system is technically present, but it is not doing much more than any with extra steps.
The real power of TypeScript sits in patterns that most engineers learn only when they have been bitten hard enough. A field that should have been narrowed was not. An API contract drifted and nobody caught it until production. A refactor broke ten callers and the compiler said nothing.
This post covers the patterns that actually solve those problems. Every section is grounded in something that comes up in real codebases. The goal is not to show off type gymnastics; it is to show you when and why each tool is worth reaching for.
The examples target TypeScript 5.4 and 5.5, which are the versions most teams are on in 2025 to 2026.
Discriminated Unions Done Properly
Discriminated unions are the single pattern most underused in production TypeScript. They look simple on the surface, but most teams do not squeeze all their value out.
The canonical form is a union of object types sharing a literal property:
type ApiState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string; retryAfter?: number };The discriminant is status. When you narrow on it, TypeScript makes every other field available without optional chaining or non-null assertions:
function render<T>(state: ApiState<T>): string {
switch (state.status) {
case 'success':
// state.data is T here, fully typed
return JSON.stringify(state.data);
case 'error':
return `Error: ${state.error}`;
default:
return 'Loading...';
}
}Where teams go wrong is the exhaustiveness check. If you add a new variant and forget to handle it, the compiler will not warn you unless you add an explicit exhaustive branch:
function assertNever(x: never): never {
throw new Error(`Unhandled variant: ${JSON.stringify(x)}`);
}
function render<T>(state: ApiState<T>): string {
switch (state.status) {
case 'idle': return '';
case 'loading': return '...';
case 'success': return JSON.stringify(state.data);
case 'error': return state.error;
default: return assertNever(state);
}
}Now if you add { status: 'cancelled' } to ApiState, the compiler errors on the assertNever call, not silently at runtime.
A second common mistake is putting too many optional fields onto a single object type instead of splitting into variants. You end up with something like { status: string; data?: T; error?: string }, which means every branch has to guard everything manually. The discriminated union pays for itself the first time you refactor.
Branded Types: Making the Compiler Enforce Domain Rules
TypeScript's structural type system means that type UserId = string is completely interchangeable with string. You can pass a ProductId where a UserId is expected and the compiler will not complain.
Branded types break that equivalence:
declare const __brand: unique symbol;
type Brand<Base, Tag> = Base & { readonly [__brand]: Tag };
type UserId = Brand<string, 'UserId'>;
type ProductId = Brand<string, 'ProductId'>;
type SessionId = Brand<string, 'SessionId'>;Now UserId and ProductId are structurally different types, even though both are strings at runtime. The compiler will reject a ProductId where a UserId is expected.
You need a constructor to create branded values, since you cannot assign a plain string directly:
function toUserId(raw: string): UserId {
if (!raw.startsWith('usr_')) throw new Error('Invalid UserId');
return raw as UserId;
}
function toProductId(raw: string): ProductId {
if (!raw.startsWith('prod_')) throw new Error('Invalid ProductId');
return raw as ProductId;
}The as cast sits in the constructor and nowhere else. The rest of the codebase is free of casts and the domain invariant is enforced by types rather than convention.
Where this gets genuinely useful is at API boundaries. Parse and brand at the edge (route handler, database query result), and from there the compiler guarantees you never swap IDs:
async function getOrdersForUser(userId: UserId): Promise<Order[]> {
return db.query('SELECT * FROM orders WHERE user_id = $1', [userId]);
}
// This call will not compile
const pid = toProductId('prod_abc');
await getOrdersForUser(pid); // Argument of type 'ProductId' is not assignable to 'UserId'The gotcha here is JSON serialisation. When you serialise a branded string to JSON and parse it back, you get a plain string. You need to re-brand at the boundary. Teams sometimes forget this and end up with untyped strings flowing back in through response parsing.
Conditional Types and the infer Keyword
Conditional types let you compute a type based on a condition, using syntax that mirrors a ternary expression:
type IsArray<T> = T extends any[] ? true : false;
type A = IsArray<string[]>; // true
type B = IsArray<string>; // falseThe real power arrives with infer, which lets you extract a type from a structural position:
type UnpackArray<T> = T extends (infer Item)[] ? Item : T;
type C = UnpackArray<string[]>; // string
type D = UnpackArray<number>; // numberA practical use case is extracting the resolved value of a promise:
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T;
// (TypeScript includes this as a built-in since 4.5)A more interesting real-world example is extracting route parameters from a string literal:
type ExtractParams<Path extends string> =
Path extends `${infer _Start}:${infer Param}/${infer Rest}`
? Param | ExtractParams<`/${Rest}`>
: Path extends `${infer _Start}:${infer Param}`
? Param
: never;
type Params = ExtractParams<'/users/:userId/posts/:postId'>;
// 'userId' | 'postId'This is exactly how libraries like React Router and tRPC derive route param types from path strings at compile time.
The failure mode to watch for is distributivity. When a conditional type is applied to a naked type parameter, TypeScript distributes it over unions:
type ToArray<T> = T extends any ? T[] : never;
type E = ToArray<string | number>; // string[] | number[], NOT (string | number)[]If that is not what you want, wrap in brackets:
type ToArray<T> = [T] extends [any] ? T[] : never;
type F = ToArray<string | number>; // (string | number)[]Getting this wrong produces subtly wrong union distributions that are hard to debug because the types look plausible.
Template Literal Types for API Contract Encoding
TypeScript 4.1 added template literal types, and they remain one of the most underused tools in the type system. They let you construct new string literal types from existing ones.
A simple example is building event name unions from a domain:
type Entity = 'user' | 'product' | 'order';
type Action = 'created' | 'updated' | 'deleted';
type DomainEvent = `${Entity}.${Action}`;
// 'user.created' | 'user.updated' | 'user.deleted'
// | 'product.created' | ...
// | 'order.created' | ... (9 variants total)This means your event bus can enforce valid event names at compile time without maintaining a large hand-written union:
declare function emit(event: DomainEvent, payload: unknown): void;
emit('user.created', { id: '...' }); // ok
emit('user.removed', { id: '...' }); // Type '"user.removed"' is not assignable to type 'DomainEvent'A more sophisticated use is CSS-in-TS, building valid direction utilities:
type Side = 'top' | 'right' | 'bottom' | 'left';
type SpacingProp = `margin${Capitalize<Side>}` | `padding${Capitalize<Side>}`;
// 'marginTop' | 'marginRight' | ... | 'paddingLeft'Or typed object access helpers:
type DeepKeys<T, Prefix extends string = ''> =
T extends object
? {
[K in keyof T & string]:
| `${Prefix}${K}`
| DeepKeys<T[K], `${Prefix}${K}.`>;
}[keyof T & string]
: never;
type Profile = { user: { name: string; age: number }; settings: { theme: string } };
type Keys = DeepKeys<Profile>;
// 'user' | 'user.name' | 'user.age' | 'settings' | 'settings.theme'This is used by form libraries and path-based utilities to prevent typos in deep property access.
The honest caveat: the TypeScript compiler starts slowing down noticeably when you build recursive template literal types over large unions. If you find type-checking taking seconds on a file full of recursive template literals, consider whether a runtime validation approach (Zod, Valibot) would give you equivalent safety with less compiler load.
Variadic Tuple Types and Function Composition
Variadic tuple types arrived in TypeScript 4.0 and made it possible to reason about argument lists as first-class values.
The core syntax uses ...T in a tuple position where T is constrained to an array:
type Concat<A extends unknown[], B extends unknown[]> = [...A, ...B];
type AB = Concat<[string, number], [boolean, null]>;
// [string, number, boolean, null]This unlocks typed function composition. Consider a pipe function that chains unary functions:
type LastOf<T extends unknown[]> =
T extends [...infer _, infer Last] ? Last : never;
type FirstOf<T extends unknown[]> =
T extends [infer First, ...infer _] ? First : never;
function pipe<Fns extends Array<(arg: any) => any>>(
...fns: Fns
): (arg: FirstOf<Parameters<Fns[0]>>) => ReturnType<LastOf<Fns>> {
return (arg) => fns.reduce((v, f) => f(v), arg) as any;
}
const transform = pipe(
(n: number) => n.toString(),
(s: string) => s.length,
(n: number) => n > 3,
);
// transform: (arg: number) => booleanThe types flow through the composition and the result is correctly typed. Without variadic tuples, you would need overloads for each arity (1 to N), which is how lodash's _.flow types are defined, a long list of manual overloads.
Another valuable application is typed event handlers that collect arguments:
type EventMap = {
click: [x: number, y: number];
keydown: [key: string, modifiers: string[]];
};
declare function on<K extends keyof EventMap>(
event: K,
handler: (...args: EventMap[K]) => void,
): void;
on('click', (x, y) => console.log(x, y)); // x: number, y: number
on('keydown', (key, mods) => console.log(key)); // key: string, mods: string[]The tuple type for each event propagates into the handler's parameter list without needing a wrapper object.
Mapped Types with Key Remapping
TypeScript 4.1 added as clauses inside mapped types, allowing you to remap keys while still iterating over the original ones. This is distinct from just picking or omitting; you can derive entirely new keys.
A useful pattern is generating getter names from a property map:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type Point = { x: number; y: number };
type PointGetters = Getters<Point>;
// { getX: () => number; getY: () => number }Or filtering a type's keys by their value type:
type PickByValue<T, V> = {
[K in keyof T as T[K] extends V ? K : never]: T[K];
};
type Config = { timeout: number; host: string; debug: boolean; port: number };
type NumericConfig = PickByValue<Config, number>;
// { timeout: number; port: number }Key remapping also composes with infer. You can build a type that converts all method properties to their return types:
type MethodReturnTypes<T> = {
[K in keyof T as T[K] extends (...args: any[]) => any ? K : never]:
T[K] extends (...args: any[]) => infer R ? R : never;
};
type Service = {
getUser: (id: string) => Promise<{ name: string }>;
deleteUser: (id: string) => Promise<void>;
config: { timeout: number };
};
type ServiceReturns = MethodReturnTypes<Service>;
// { getUser: Promise<{ name: string }>; deleteUser: Promise<void> }
// config is excluded because it is not a functionThe thing to watch out for is the cognitive cost. A mapped type with key remapping and infer nested inside it is hard to read at a glance. Keep these types named, documented, and in a dedicated types.ts file rather than inline in component or function files.
The satisfies Operator: Inference Without Widening
TypeScript 4.9 added satisfies, and it solves a real problem that was previously awkward.
When you annotate a variable directly, TypeScript widens the type to the annotation:
const palette: Record<string, string[]> = {
red: ['#ff0000', '#cc0000'],
blue: ['#0000ff'],
};
palette.red.map(hex => hex.toUpperCase()); // ok
palette.green; // ok at compile time (returns string[] | undefined? No, just string[])The annotation loses the specific keys and values. You cannot use palette.red without the widened type pretending the value might be string[] in general.
With satisfies, the type is checked but the inferred type is preserved:
const palette = {
red: ['#ff0000', '#cc0000'],
blue: ['#0000ff'],
} satisfies Record<string, string[]>;
// palette still has type { red: string[]; blue: string[] }
// The specific keys are preserved
// But you still get an error if you add a non-string[] value:
const broken = {
red: '#ff0000', // Error: string is not assignable to string[]
} satisfies Record<string, string[]>;A practical use case is route config objects:
type RouteConfig = {
path: string;
auth: boolean;
roles?: string[];
};
const routes = {
home: { path: '/', auth: false },
profile: { path: '/profile', auth: true, roles: ['user', 'admin'] },
admin: { path: '/admin', auth: true, roles: ['admin'] },
} satisfies Record<string, RouteConfig>;
// routes.home.path is string (not widened to just RouteConfig)
// routes.home.roles is undefined | string[], not just string[]
// And you cannot put in an invalid config without a compile errorBefore satisfies, you either lost the specific key types by annotating, or lost the constraint check by not annotating. This operator gives you both.
How to Actually Apply These Patterns
The worst outcome is reading this post and spending a week retrofitting clever types into a codebase that does not need them. These patterns earn their keep in specific situations:
Discriminated unions belong at any boundary where a value can be in multiple distinct states: API responses, async state machines, form validation results. If you find yourself writing if (result.error) { ... } else if (result.loading) { ... }, you want a discriminated union.
Branded types are worth adding when you have multiple IDs of different domain types, or when you have numeric values with different units (dollars vs cents, seconds vs milliseconds). Start narrow: brand just the types where you have already had a bug from mixing them up.
Conditional types and infer are most useful at library boundaries, form libraries, router types, ORM column mappings. If you are writing application code, reaching for infer is often a sign you are overcomplicating things. Use the built-in utility types first.
Template literal types are valuable when you have string-based APIs with enumerable structures: event names, CSS properties, API endpoint paths, i18n key namespaces. They catch typos that would otherwise be silent runtime errors.
Variadic tuples pay off in utility code that composes functions or handles variable-argument events. They are overkill for most application code.
satisfies should replace most of your constant object annotations. It is a strictly better default than const obj: SomeType = { ... } when you want both constraint checking and specific inference.
The honest lesson from production experience: most teams would benefit most from discriminated unions and satisfies, because they solve the most common class of runtime bugs without adding much complexity. The other patterns are genuinely useful but in narrower contexts.
Gotcha: Type Widening in Generic Inference
One real experience worth sharing: a colleague built a typed configuration system using generics and was puzzled why the type of a config value was widening to string instead of staying as a specific literal.
The code looked like this:
function defineConfig<T extends Record<string, string>>(config: T): T {
return config;
}
const config = defineConfig({ env: 'production', region: 'eu-west-1' });
// config.env is string, not 'production'TypeScript infers string literals as string when passed as generic arguments unless you force literal inference. You can do this with as const:
const config = defineConfig({ env: 'production', region: 'eu-west-1' } as const);
// config.env is 'production'Or by constraining the generic differently:
function defineConfig<T extends Record<string, string>>(
config: { [K in keyof T]: T[K] },
): { readonly [K in keyof T]: T[K] } {
return config as any;
}The subtlety is that TypeScript's inference of literal types is context-dependent. Inside an object literal passed to a generic that expects string, it widens. With as const, the compiler treats every value as its literal type. This tripped up experienced engineers who knew generics well but had not internalised the widening rules.
Key Takeaways
- Discriminated unions with
assertNevergive you exhaustive narrowing that catches missed cases at compile time, not runtime. - Branded types enforce domain invariants structurally. Create them at validation boundaries; keep the
ascast inside the constructor. inferextracts types from structural positions inside conditional types. Watch for unintended distributivity when the type parameter is naked.- Template literal types encode string-based API contracts as union types. Useful, but recursive versions are slow to compile over large unions.
- Variadic tuples make typed function composition and variable-arity event handlers tractable. They eliminate overload lists.
- Mapped types with
asclauses remap keys conditionally, enabling patterns like filtering to only method keys or generating getter names. satisfieschecks a type constraint without widening the inferred type. It should be your default for constant object declarations.- Type widening in generic inference surprises experienced engineers. Use
as constor rethink your constraint shape before adding casts.
None of these are exotic. They are all in regular TypeScript and supported in every modern IDE. The skill is knowing which one fits the problem in front of you.