Dev.to WebDev 🛠 Dev 👁 0 📖 4 min read

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.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.