wwohlers
wwohlers

Reputation: 379

How to avoid distributive conditional types

These are the types I have:

type Action<T> = T extends undefined ? {
    type: string;
} : {
    type: string;
    payload: T;
}

type ApiResponse<T> = {
    ok: false;
    error: string;
} | {
    ok: true;
    data: T;
};

I've defined this function:

function handleApiResponse<T>(apiResponse: ApiResponse<T>) {
    const a: Action<ApiResponse<T>> = {
        type: "response",
        payload: apiResponse,
    }
}

The problem is a has an error because the conditional type in Action is distributed over ApiResponse.

What I need from Action<T> is a type that is literally what it says, i.e., there are two cases:

  1. The type parameter passed to Action<T> is undefined, in which case Action<undefined> = { type: string }
  2. The type parameter passed to Action<T> is anything else, in which case Action<T> = { type: string, payload: T }

But this doesn't work when T is a union type because of distributed conditional types.

How can I define a type like this that works even when T is a union?

Upvotes: 6

Views: 1339

Answers (1)

jcalz
jcalz

Reputation: 329598

As you mentioned, your definition of Action<T> is a distributive conditional type, in which unions in T are split up into their individual members (e.g., B | C | D), passed through Action<T>, and the result put back into a new union (e.g., Action<B> | Action<C> | Action<D>). This is often the behavior people want from their conditional types, but it's not what you want here; that is, you've used a distributive conditional type accidentally.

A conditional type of the form TTT extends UUU ? VVV : WWW will only be distributive if the type TTT that you're checking is a bare generic type parameter like the T in your definition of Action<T>. If it's some specific type (like string or Date), it won't be distributive. And if it's a more complicated expression involving a type parameter (like {x: T}), it won't be distributive. I sometimes refer to this latter idea, where you take the "bare" type parameter T and do something to it like {x: T}, as "clothing" the type parameter.


So you've got T extends undefined, which is distributive:

type Action<T> = T extends undefined ? ... : ...

If you want to turn that off, you need to rephrase the check so that T is clothed but where it has the same behavior (so the check succeeds if and only if T extends undefined). The easiest way to do this is to clothe both sides of the extends clause to be a one-element tuple type), so T becomes [T] and undefined becomes [undefined]:

type Action<T> = [T] extends [undefined] ? ... : ...

and then your code works:

function handleApiResponse<T>(apiResponse: ApiResponse<T>) {
  const a: Action<ApiResponse<T>> = { 
    type: "response",
    payload: apiResponse,
  } // okay
}

Note that TypeScript considers tuples and arrays to be covariant in their element types, meaning that Array<X> extends Array<Y> or [X] extends [Y] if and only if X extends Y. This is technically unsafe to do (see this question and its answers for more info) but very convenient.


So that's a general rule you can use: if you have TTT extends UUU ? VVV : WWW and it's accidentally distributive, you can turn that off by writing [TTT] extends [UUU] ? VVV : WWW. This is mentioned at the bottom of the documentation for distributive conditional types, although it just sort of says "do this" and not why it works. It might look like the square brackets are some kind of special syntax designed for conditional types... but it's not. It's just using existing one-element tuple syntax, which happens to do what we want without requiring too many more characters.

Playground link to code

Upvotes: 13

Related Questions