Day 25/90: Conditional Types - The API Wrapper That Returned any
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, retrie
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.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.