omiid
homenotebookai usage

TypeScript conditional types and how infer works

May 04, 2026 · Updated on August 09, 2026

A conditional type picks one of two types based on whether one type is assignable to another. It is TypeScript's ternary operator, applied at the type level.

The syntax is:

type Result = A extends B ? C : D;

The word extends is the confusing part. This is not class inheritance. It means assignability: can a value of type A be used where a B is expected? If yes, resolve to C. If no, resolve to D.

type IsString<T> = T extends string ? "yes" : "no";
 
type A = IsString<string>; // "yes"
type B = IsString<number>; // "no"
type C = IsString<"hello">; // "yes" - string literals are assignable to string

This is purely a compile-time check. Nothing happens at runtime.

The official docs for the feature live in the TypeScript handbook, under conditional types. The two parts that cause the most confusion in real code are distribution over unions and pattern matching with infer.

I write short, practical notes like this one. Get the next one by email:

Unsubscribe anytime.

The infer keyword pulls a type out of another type

infer is what makes conditional types genuinely useful. It lets you extract a type from inside another type while you're pattern-matching it.

type UnwrapPromise<T> = T extends Promise<infer Inner> ? Inner : T;
 
type A = UnwrapPromise<Promise<string>>; // string
type B = UnwrapPromise<number>; // number (not a Promise, so returns T)

You're saying: "if T matches Promise<something>, capture that something into Inner." TypeScript figures out what Inner must be.

This is how ReturnType<T> is implemented in the standard library:

type ReturnType<T extends (...args: any) => any> = T extends (
	...args: any
) => infer R
	? R
	: never;

A few more patterns that come up in real code:

// Extract the first argument type of a function
type FirstArg<T extends (...args: any) => any> = T extends (
	first: infer F,
	...rest: any
) => any
	? F
	: never;
 
type F = FirstArg<(id: string, options: { limit: number }) => void>;
// F = string
 
// Unwrap an array element type
type ElementOf<T> = T extends Array<infer E> ? E : T;
 
type E = ElementOf<string[]>; // string
type F = ElementOf<number>; // number (passthrough)

One thing worth noting: you don't always need infer. If the type is accessible through indexing, that's simpler:

// No need for infer here
type Value = Record<string, number>[string]; // number

Save infer for when you need to pattern-match a shape that TypeScript can't reach through direct indexing.

Distributive conditional types split the union before the check runs

This is where most conditional type bugs come from.

When the type being checked (T) is a naked type parameter (not wrapped in a tuple, object, or anything else), TypeScript automatically distributes the condition across each member of a union:

type ToArray<T> = T extends any ? T[] : never;
 
type A = ToArray<string | number>;
// TypeScript expands this to:
// ToArray<string> | ToArray<number>
// = string[] | number[]

That's often exactly what you want. But sometimes it isn't:

type IsString<T> = T extends string ? "yes" : "no";
 
type A = IsString<string | number>;
// = "yes" | "no"  (distributed - each member checked separately)

If you want to check the whole union against string, wrap both sides in square brackets to opt out of distribution:

type IsString<T> = [T] extends [string] ? "yes" : "no";
 
type A = IsString<string | number>;
// = "no"  (the union as a whole is not assignable to string)

The [T] extends [string] form checks assignability once on the tuple, so distribution doesn't kick in.

Three places conditional types earn their keep

Extract an event payload from a discriminated union

Say you have a union of app events:

type AppEvent =
	| { type: "user.created"; payload: { id: string; email: string } }
	| { type: "order.placed"; payload: { orderId: string; amount: number } }
	| { type: "item.removed"; payload: { itemId: string } };

You can extract the payload for a specific event type without conditional types, because Extract and index access is enough:

type PayloadFor<T extends AppEvent["type"]> = Extract<
	AppEvent,
	{ type: T }
>["payload"];
 
type UserCreatedPayload = PayloadFor<"user.created">;
// { id: string; email: string }

But if your event structure is more complex or the shape varies, infer earns its keep:

type ExtractPayload<T, K extends string> = T extends {
	type: K;
	payload: infer P;
}
	? P
	: never;
 
type P = ExtractPayload<AppEvent, "order.placed">;
// { orderId: string; amount: number }

Give a function a conditional return type

When the return shape depends on a literal argument, a conditional return type ties the two together in one signature:

type FetchResult<T extends "raw" | "parsed"> = T extends "raw"
	? Buffer
	: { data: unknown; status: number };
 
function fetch<T extends "raw" | "parsed">(
	url: string,
	format: T,
): Promise<FetchResult<T>>;

Callers get the right return type automatically based on what they pass for format. No overloads needed.

Strip null and undefined the way NonNullable does

Distribution does the real work in this utility type, so the body is a single line:

type StripNullish<T> = T extends null | undefined ? never : T;
 
type A = StripNullish<string | null | undefined>; // string

The distributive behavior is what makes this work. TypeScript applies the condition to each union member, strips the nullish ones, and merges the rest. This is exactly how the built-in NonNullable<T> works.

Reach for overloads or discriminated unions first

Conditional types add cognitive overhead. Before writing one, check if the problem fits a simpler tool.

Function overloads are often cleaner when you need different return types based on argument types:

function process(input: string): string[];
function process(input: number): number;
function process(input: string | number): string[] | number {
	// ...
}

Discriminated unions work better when you're narrowing based on a shared property in application code:

type Result = { ok: true; value: string } | { ok: false; error: Error };

Use conditional types when:

  • You're writing a utility type that transforms or extracts from a type parameter
  • You need to pattern-match a shape you can't reach through direct indexing
  • You're building library-level or shared utility types

If you're in application code and squinting at the type signature, it's probably overengineered. Discriminated unions or overloads will be easier for whoever reads it next, including you.


For a longer worked example, I wrote a similar deep dive into building a type-safe groupBy function in TypeScript that puts some of these patterns to use.

Join My Newsletter

Occasional notes on software, tools, and things I learn. No spam.

Unsubscribe anytime.

Continue Reading
  • Tuning Postgres and pgvector: the three knobs that matter08-18-2026 · Most pgvector performance problems come down to three settings. This post shows how to read an ANN query plan and tune ef_search, shared_buffers, and work_mem in the right order.
  • HNSW vs IVFFlat: choosing and building your pgvector index08-14-2026 · Past a few hundred thousand rows, an exact scan stops being fast enough. Here is how to pick between HNSW and IVFFlat and build the index without locking the table.
  • Vector search relevance: chunking, metadata, and the 0.81 problem08-11-2026 · Most bad vector search results come from one of three failure modes: chunking, modality mismatch, or a confused model. Each one has a specific diagnostic and a specific fix.
  • pgvector setup: your first multimodal query in TypeScript08-03-2026 · One Postgres table can hold text and screenshot embeddings in the same vector column. This post sets up the schema, the Voyage embedding call, and the first query that returns both.
  • Vector search in Postgres: the mental model behind pgvector08-02-2026 · A first similarity query that returns results is not a finished search feature. This post explains what an embedding is, why one Postgres column can hold text and images, and where an untuned index starts returning wrong results.