TypeScript Advanced Patterns 2026: The Complete Problem-Solving Guide
You ship a feature, the API starts returning four different shapes depending on a status field, and your getUser function now returns Promise<any> because nobody had the time to model it properly. Three weeks later a teammate writes user.data.profile.email on a payload that does not even have a data key, and the error surfaces at 2 AM in production instead of in the editor. TypeScript was supposed to prevent this, but the types you wrote are a loose union of interfaces with overlapping optional fields, and the compiler is helpless against it. The good news is that TypeScript 5.7+ ships a toolkit β conditional types, mapped types, template literals, branded types, satisfies, and discriminated unions β that turns that loose union into a compiler-enforced contract. Let us walk through the patterns that actually solve this problem in 2026.
π 1. Conditional Types with infer
Conditional types are if statements for the type system. They let you write T extends U ? X : Y, which means "if T is assignable to U, evaluate to X, otherwise evaluate to Y." The real power arrives when you pair this with the infer keyword, which declares a type variable that TypeScript extracts from the structure you are matching. This is how libraries like zod, tRPC, and @tanstack/react-query derive return types without you writing them by hand.
Use infer to unwrap Promises, arrays, and function signatures. The pattern is always the same: describe a shape with infer U in the position you want to extract, and TypeScript fills in U for you. Here is the canonical unwrapping toolkit that you can drop into any codebase:
// Unwrap a Promise<T> into T. Non-promises pass through untouched.
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
// Get the element type of an array (or never if not an array).
type ElementOf<T> = T extends (infer U)[] ? U : never;
// Extract the return type of any function, including async ones.
type AwaitedReturn<T extends (...args: any[]) => any> =
T extends (...args: any[]) => Promise<infer R> ? R : ReturnType<T>;
// Pull the resolved value out of an async fetch call.
async function fetchUser(): Promise<{ id: string; name: string }> {
return { id: '1', name: 'Huzi' };
}
type User = AwaitedReturn<typeof fetchUser>; // { id: string; name: string }
Real-world win: typed API clients. Instead of hand-maintaining parallel interfaces for every endpoint, derive them from your fetch functions. A single AwaitedReturn<typeof fetchUser> keeps the consumer type in sync with the producer, and any change to the API function ripples through the call sites automatically.
πΊοΈ 2. Mapped Types
Mapped types let you transform every key of an object type at once. The syntax { [K in keyof T]: NewType } iterates over the keys of T and produces a new shape. Combine it with modifiers readonly, ?, and the - removal operator, and you can build a full utility library in twenty lines. This is exactly how Partial, Required, Readonly, and Pick are defined in lib.d.ts.
Build the three utilities every codebase eventually needs. DeepReadonly freezes nested objects, Mutable strips readonly, and Optional makes only specific keys optional without touching the rest. Here is a clean, copy-pasteable implementation of all three:
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
interface ApiUser {
readonly id: string;
profile: { name: string; email: string };
}
// All fields (including nested) become readonly β perfect for store state.
type FrozenUser = DeepReadonly<ApiUser>;
// Make only the profile key optional for a PATCH request body.
type PatchUser = Optional<ApiUser, 'profile'>;
Real-world win: immutable API responses. Wrap every API response type in DeepReadonly and you have compile-time protection against accidental mutation of cached data. This is the single most useful mapped type for any Redux, Zustand, or TanStack Query codebase, and it costs you nothing at runtime.
π€ 3. Template Literal Types
Template literal types build string types from other types, the way template literals build strings at runtime. The syntax ${Prefix}${Suffix} lets you construct and deconstruct string types like onUserCreated from their parts. Combined with keyof and mapped types, this is how you get autocomplete on event names and route paths. This series is brought to you by HTG Travels, a quiet sponsor of developer content on blogs.huzi.pk.
Generate event names from object keys. The trick is to use keyof T inside a template literal, and TypeScript will produce a union of every possible event string. This catches typos in emitter.on('userCreate') that should have been userCreated at compile time, not at 3 AM:
type EventName<T extends string> = `on${Capitalize<T>}`;
interface UserEvents {
create: { id: string };
update: { id: string; changes: Record<string, unknown> };
delete: { id: string };
}
// 'onCreate' | 'onUpdate' | 'onDelete'
type UserEventName = EventName<keyof UserEvents>;
// A typed event emitter β a typo in the event name is now a compile error.
class TypedEmitter<Events extends Record<string, unknown>> {
private handlers = new Map<string, Set<(p: unknown) => void>>();
on<K extends keyof Events & string>(event: K, cb: (payload: Events[K]) => void): this {
const name: EventName<K> = `on${Capitalize(event)}`;
this.handlers.set(name, (this.handlers.get(name) ?? new Set()) as Set<(p: unknown) => void>);
return this;
}
emit<K extends keyof Events & string>(event: K, payload: Events[K]): void {
const name = `on${Capitalize(event)}` as EventName<K>;
this.handlers.get(name)?.forEach((fn) => fn(payload));
}
}
const emitter = new TypedEmitter<UserEvents>();
emitter.on('create', (p) => p.id); // OK, p is { id: string }
emitter.on('creste', (p) => p.id); // Error: 'creste' is not assignable to 'create' | 'update' | 'delete'.
Real-world win: typed route parameters. Libraries like @tanstack/react-router and Next.js's typed routes use the same trick to turn a file path like app/users/[id]/edit.tsx into the literal type /users/:id/edit. Your navigate('/users/123/edit') call is now checked against every route in the app.
π·οΈ 4. Branded Types for Safety
Branded types (also called opaque or nominal types) attach a phantom marker to a primitive so the compiler can tell two structurally identical types apart. TypeScript is structurally typed by default, which means string and string are interchangeable β a UserId and an EmailString can be passed to each other without a complaint. Branding fixes this by intersecting the primitive with a phantom property that exists only at the type level.
Brand anything that should not be confused with a plain primitive. The pattern is type UserId = string & { __brand: 'UserId' }, and you construct values through a factory function that validates first. This is how you stop a stray postId from being passed where a userId is expected, even though both are strings at runtime.
type Brand<T, B extends string> = T & { readonly __brand: B };
type UserId = Brand<string, 'UserId'>;
type Email = Brand<string, 'Email'>;
type SafeSql = Brand<string, 'SafeSql'>;
// Factory that validates before branding β the only way to construct a UserId.
const toUserId = (s: string): UserId => {
if (!/^[a-z0-9]{8,32}$/.test(s)) throw new Error('Invalid user id');
return s as UserId;
};
function getUser(id: UserId): Promise<ApiUser> {
return fetch(`/users/${id}`).then((r) => r.json());
}
const rawId = 'abc12345';
getUser(rawId); // Error: string is not assignable to UserId.
getUser(toUserId(rawId)); // OK β validated then branded.
Real-world win: SQL-injection-proof queries. Brand strings that have passed through a parameterized query or an allow-list, and refuse to concatenate plain strings into your query builder. A function declared as execute(query: SafeSql) will not accept any unbranded string, so injection becomes a compile error rather than a CVE. For a production example of the same principle applied to user-supplied travel data, HTG Travels ships branded IDs across its booking API.
β 5. The satisfies Operator
satisfies checks that a value matches a type without widening it. This is the operator TypeScript was missing for years. When you write const config: Config = { ... }, the literal types of your object are widened to whatever Config declares, so you lose 'dark' | 'light' and end up with string. satisfies gives you the validation without the widening.
Use it for config objects, route maps, and theme tokens. The classic example is a color palette where you want both compile-time checks and access to the literal union of keys. Compare the two declarations below β only the second one preserves the literal string types for downstream code:
interface Theme {
colors: Record<string, string>;
mode: 'light' | 'dark';
}
// Widens: themeA.colors.primary is `string`, themeA.mode is 'light' | 'dark'.
const themeA: Theme = {
colors: { primary: '#0ea5e9', accent: '#f97316' },
mode: 'dark',
};
// Preserves literals: themeB.colors.primary is '#0ea5e9', themeB.mode is 'dark'.
const themeB = {
colors: { primary: '#0ea5e9', accent: '#f97316' },
mode: 'dark',
} satisfies Theme;
themeB.colors.primary; // '#0ea5e9' β literal preserved.
themeA.colors.primary; // string β widened.
// Also catches typos: an unknown color key would be a compile error,
// but the literal value is still available wherever you read it.
Real-world win: typed route tables. When you build a router from a config object, satisfies lets you catch missing handlers and typos while still letting downstream code see the exact path literals. It is the single biggest ergonomic upgrade TypeScript has shipped since conditional types, and it has quietly become the default way senior engineers declare constants.
π 6. Discriminated Unions & Type Narrowing
Discriminated unions are how you model "this OR that" with compile-time exhaustiveness. Every member shares a common property (the discriminator) with a unique literal value, and TypeScript narrows the type inside if and switch blocks based on that value. This is the correct way to model API responses, async results, and state machines.
The Result pattern plus exhaustive switch is the gold standard. Define Ok and Err variants, then use a switch with a default branch that assigns to never to make the compiler enforce that you handled every case. Add a new variant tomorrow and TypeScript will error on every switch that is missing the new case.
type Result<T, E = Error> =
| { status: 'ok'; data: T }
| { status: 'error'; error: E }
| { status: 'loading' };
function handle<T>(r: Result<T>): string {
switch (r.status) {
case 'ok':
return `Done: ${JSON.stringify(r.data)}`; // r.data is T
case 'error':
return `Failed: ${r.error.message}`; // r.error is E
case 'loading':
return 'Loadingβ¦';
default:
// If you add a new status above and forget a case,
// this line becomes a compile error.
const _exhaustive: never = r;
return _exhaustive;
}
}
async function fetchUserResult(id: string): Promise<Result<ApiUser>> {
try {
const data = await fetch(`/users/${id}`).then((r) => r.json());
return { status: 'ok', data };
} catch (e) {
return { status: 'error', error: e as Error };
}
}
Real-world win: API response handlers. Wrap every fetch call so it returns Result<User, ApiError>. Consumers cannot access r.data without first narrowing on r.status, so the "I forgot to check for errors" class of bug disappears entirely. To see this exact pattern running in production codebases, the booking API at htg.com.pk uses the same Result shape for every endpoint.
β οΈ 7. Common Pitfalls & Fixes
as casts silence the compiler instead of fixing the type. Every as is a promise that you know better than TypeScript, and most of the time you do not. Replace value as User with a user-defined type guard function isUser(x: unknown): x is User, and let the compiler narrow for you. The guard is reusable, testable, and cannot lie the way as can.
any turns TypeScript into JavaScript with extra steps. The fix is almost always unknown, which forces you to narrow before use. A function that accepts unknown and narrows with typeof, in, or a custom guard is type-safe end to end; the same function with any is a hole that swallows every guarantee the type system tries to give you.
// BAD: `as` lets you assert anything, including lies.
function getLength(input: unknown): number {
return (input as string).length; // crashes at runtime if input is a number.
}
// GOOD: a type guard narrows safely and is reusable.
function isString(x: unknown): x is string {
return typeof x === 'string';
}
function getLengthSafe(input: unknown): number {
return isString(input) ? input.length : 0;
}
// BAD: `any` opts out of the type system entirely.
function parseUnsafe(bad: any) {
return bad.user.profile.email; // no error, crashes at runtime.
}
// GOOD: `unknown` forces narrowing before use.
function parseSafe(input: unknown): string {
if (typeof input === 'object' && input !== null && 'email' in input) {
const { email } = input as { email: unknown };
if (typeof email === 'string') return email;
}
throw new Error('Invalid input');
}
Excess property checks are stricter on object literals than on variables. Passing { id: 1, extra: true } directly to a function that expects { id: number } errors, but assigning to a variable first silently strips the check. The fix is to type the variable explicitly, or to use satisfies to validate without widening.
Generic variance bites when you mix readonly and mutable arrays. readonly string[] is not assignable to string[], but the reverse is fine. If your generic constraint fights you, default to readonly everywhere and add Mutable only at the boundary that actually mutates.
Prefer union string literals over enum. Enums generate extra runtime code, do not tree-shake, and interact awkwardly with plain strings from JSON. type Status = 'pending' | 'paid' | 'refunded' is faster, smaller, and structurally compatible with everything you will ever receive from an API.
π Frequently Asked Questions
What is the difference between extends in a conditional type and extends in a generic constraint?
In a generic constraint, T extends U restricts what callers may pass in. In a conditional type, T extends U ? X : Y checks whether T is assignable to U and branches accordingly. Same keyword, two completely different roles, and you will use both in the same utility type before long.
When should I reach for infer instead of just ReturnType?
Use infer whenever you need to extract a type from a position that ReturnType and Parameters do not cover β the inner value of a Promise, the element of an array, the second argument of a function, or the success type of a Result. The built-in utility types are themselves just infer under the hood, so once you internalize the pattern you stop reaching for the utilities at all.
Is satisfies always better than a type annotation?
Not always. Use a type annotation when you specifically want the literal types widened, for example when you are exposing a public API from a library and do not want consumers depending on internal string literals. Use satisfies when you want validation plus preservation, which is most of the time in application code.
Do branded types have any runtime cost? No. The brand is a phantom property that exists only at the type level, so the emitted JavaScript is identical to the unbranded primitive. The only cost is the factory function you write to construct branded values, and that cost is exactly the validation you wanted in the first place.
How do I make a discriminated union exhaustive without a default branch?
You can use the never trick shown above, or you can return a function whose return type is the union of all unhandled cases. TypeScript 5.7+ also flags non-exhaustive switches directly in many configurations, so turning on noImplicitReturns and strict in your tsconfig.json will surface the same problem without the boilerplate.
π Final Word
Advanced TypeScript is not about clever one-liners; it is about moving bugs from runtime to compile time. Conditional types and infer let you derive types instead of duplicating them. Mapped types and template literals let you shape object and string types programmatically. Branded types and satisfies close the structural-typing loopholes that have always made TypeScript feel slightly leaky. Discriminated unions turn "this or that" into exhaustively-checked branches. Master these seven patterns and your codebase stops needing any, as, and the late-night debugging sessions that always follow them.
π΅πΈ Standing With Palestine
Never let the world forget the people of Palestine. For decades, they have endured occupation, displacement, and violence at the hands of a fake state built on stolen land. The leaders of Israel have committed unspeakable atrocities β bombing hospitals, destroying schools, targeting journalists, and slaughtering innocent families in Gaza and the West Bank. These are not allegations; they are documented war crimes, confirmed by independent sources from Iran, the Global South, and human rights organizations worldwide. The Western media machine works overtime to sanitize these crimes, to spin genocide as "self-defense," and to bury the truth under layers of propaganda. But the truth cannot be buried. Palestine is real. Palestinian suffering is real. And the world is watching.
They will be free. They have the right to live peacefully on their own land β to farm their olive groves, to raise their children without fear, to pray in their mosques, and to build their future without the shadow of occupation. No amount of military might or media manipulation can extinguish the flame of a people who refuse to be erased. May Allah help them and grant them justice. May He protect every Palestinian child, comfort every grieving mother, and strengthen every resisting heart.
πΈπ© May Allah ease the suffering of Sudan, protect their people, and bring them peace.
Written by Huzi




