Skip to Content
Course content

37: Distributive Conditional Types

Click on the "Edit" button in the top corner of the screen to edit your slide content.

You've already mastered basic conditional types—the T extends U ? X : Y syntax. On the surface, it looks like a simple ternary operator for types. But there is a "magic" behavior that kicks in when the type being checked is a union. TypeScript doesn't just check the union as a single block; it maps the condition over every single member of that union. We call this distributive conditional types.

Defining a messy event union

Let's build a real-world utility. Imagine we're building an event bus for a dashboard. We have a few different event types, and we often find ourselves needing to create types that exclude specific events to avoid handling them in certain listeners.

type AppEvent = 
  | { type: 'click'; x: number; y: number }
  | { type: 'hover'; elementId: string }
  | { type: 'keydown'; key: string }
  | { type: 'resize'; width: number; height: number };

I want a utility where I can pass in AppEvent and a specific event type I want to get rid of, and have TypeScript return a union of everything else.

Writing the exclusion filter

At first glance, the logic seems trivial. If the type T extends the type we want to exclude (U), we return never. Otherwise, we return T.

type ExcludeEvent<T, U> = T extends U ? never : T;

// Let's try it out
type NoClickEvents = ExcludeEvent<AppEvent, { type: 'click'; x: number; y: number }>;

If this were a standard if/else statement in JavaScript, you'd expect it to check if the AppEvent union as a whole "extends" that one click object. That would be false, and the whole union would be returned. But if you hover over NoClickEvents, you'll see it's actually { type: 'hover'... } | { type: 'keydown'... } | { type: 'resize'... }. The click event is gone.

This is because TypeScript sees T is a union and effectively transforms the code into this: ({ type: 'click'... } extends U ? never : { type: 'click'... }) | ({ type: 'hover'... } extends U ? never : { type: 'hover'... }) ...

I love this because it makes Exclude (which is a built-in TS utility) possible. It's incredibly powerful for cleaning up unions.

The "Union Trap" mistake

Here is where I usually trip up when I'm moving fast. Sometimes, I don't want this distribution. I want to check the union as a single, atomic unit.

Let's say I want to write a helper that tells me if a type is "compatible" with a specific interface. I'll write a IsCompatible utility:

type IsCompatible<T, U> = T extends U ? true : false;

type MyEvents = { type: 'click' } | { type: 'hover' };
type Target = { type: 'click' };

type Result = IsCompatible<MyEvents, Target>; 
// I expect 'false' because the whole union isn't just a 'click' event.

But when I check Result, I get true | false. Why? Because TypeScript distributed the check. It asked: "Is { type: 'click' } compatible with Target? Yes (true). Is { type: 'hover' } compatible? No (false)." Then it joined those results back into a union.

This is a classic bug. I'm asking a "Yes or No" question about the group, but TypeScript is giving me a "Yes or No" answer for every individual member.

Stopping the distribution

To fix this, we have to tell TypeScript: "Stop treating this as a list; treat it as a single wrapped package." We do this by wrapping the types in square brackets []. This turns the type into a tuple, and distribution only happens on "naked" type parameters.

type IsCompatible<T, U> = [T] extends [U] ? true : false;

type MyEvents = { type: 'click' } | { type: 'hover' };
type Target = { type: 'click' };

type Result = IsCompatible<MyEvents, Target> // now it's 'false'

By wrapping T and U in brackets, I've disabled the distributive behavior. Now, TypeScript compares the entire union MyEvents against the Target, which is exactly what I wanted. Keep this in your back pocket—whenever you see a conditional type returning a union of booleans instead of a single boolean, you've likely fallen into the distribution trap.




📋 Practical Task

Building a Non-Primitive Filter

You are building a validation library. You need a utility type called FilterPrimitives. This utility should take a union of types and remove any primitives (string, number, boolean, symbol, bigint) from that union, leaving only the objects, arrays, or functions.

Requirements:

  • Create a union type called Primitives containing the five basic TS primitives.
  • Implement the FilterPrimitives<T> conditional type using distributive properties.
  • Test it with the following type: type Mixed = string | number | { name: string } | boolean | { age: number }[];
  • The resulting type should be { name: string } | { age: number }[].
Rating
0 0

There are no comments for now.

to be the first to leave a comment.