Day 25/90: Conditional Types - The API Wrapper That Returned any
90 Day TypeScript Challenge: Zero to Production
We built a thin request helper around fetch so every endpoint went through one place that logged, retried, and parsed JSON. The function worked. Then a teammate wrote order.totalCent instead of order.totalCents, the build stayed green, and a customer saw $NaN in their cart. The typo survived tsc because the helper had quietly started returning any.
How a wrapper returns any without anyone deciding it should
The DOM lib types res.json() as Promise<any>. Return that directly, and the any becomes your return type.
async function request(path: string): Promise<any> { const res = await fetch(path); return res.json(); } interface Order { id: string; totalCents: number; } const order = await request("/orders/42"); order.totalCent; // no error: order is any order.literallyWhatever; // also fine. the compiler has checked out. Once request returns Promise<any>, await request(...) is any, and any is contagious - every property access on it is legal, so the typo type-checks and every call site loses autocomplete.
Conditional types: a type-level if
A conditional type has the shape T extends U ? X : Y. If T is assignable to U, the result is X; otherwise it is Y. On its own that is a branch. The power comes from pairing it with infer, which pattern-matches a type and captures part of it.
type Unwrap<T> = T extends Promise<infer U> ? U : T; type One = Unwrap<Promise<Order>>; // Order type Two = Unwrap<number>; // number (not a Promise, takes the else branch) Promise<infer U> means "if T is a Promise of something, name that something U and return it." infer declares a fresh type variable filled in by structural matching. The same idea extracts an array's element type:
type Element<T> = T extends (infer E)[] ? E : T; type Str = Element<string[]>; // string type Same = Element<boolean>; // boolean Reattaching the type at the await boundary
Now make the helper generic so the inferred type flows to the caller. I add a status check on the way through:
async function request<T>(path: string): Promise<T> { const res = await fetch(path); if (!res.ok) { throw new Error(`request to ${path} failed with ${res.status}`); } const parsed = (await res.json()) as T; return parsed; } const order = await request<Order>("/orders/42"); order.totalCent; // Property 'totalCent' does not exist on type 'Order'. Did you mean 'totalCents'? request<Order> returns Promise<Order>, await unwraps it to Order, and the typo is a compile error again - with a "did you mean" suggestion. The await seam is exactly where the caller's type belongs.
Distributive conditional types
Here is the behavior that surprises people: conditional types distribute over unions. When the input is a union, TypeScript checks each member separately and unions the results.
type Unwrapped = Unwrap<Promise<Order> | string>; // Unwrap<Promise<Order>> | Unwrap<string> => Order | string That is usually what you want. When it is not, wrap both sides of the extends in a one-tuple to disable distribution:
type IsString<T> = T extends string ? true : false; type A = IsString<string | number>; // boolean (true | false), checked per member type IsStringStrict<T> = [T] extends [string] ? true : false; type B = IsStringStrict<string | number>; // false, the whole union tested at once [T] extends [U] is the standard idiom for "treat the union as one thing."
Building NonNullable from scratch
With distribution understood, the standard utilities are two-liners. NonNullable<T> strips null and undefined precisely because it distributes and lets them fall to a never branch. Here it is, with a non-nullish member added to the input so you can see the survivors:
type Strip<T> = T extends undefined ? never : T extends null ? never : T; type Kept = Strip<number | null | undefined | boolean>; // number tested -> kept; null -> never; undefined -> never; boolean -> kept // number | never | never | boolean => number | boolean never vanishes from a union, so the nullish members are simply removed while number and boolean survive. Awaited and ReturnType follow the same recipe: a conditional type with an infer to capture the inner type, leaning on distribution where it helps.
The takeaway
A wrapper that returns any is worse than no wrapper, because it silences the compiler for everyone downstream of it. When res.json() hands you Promise<any>, that is the seam where you make the function generic and recover the caller's type with a conditional type and infer. The $NaN in the cart was the bill for letting any cross a boundary I owned - and a conditional type is how you stop owning that hole.