Distributive Conditional Types in TypeScript

The following post was originally published on my omg.lol Weblog on December 17th, 2023. It's an adaptation of some notes I took on the subject while writing TS.

In TypeScript, I often define string union types like so:

type FavoriteLanguage = 'typescript' | 'golang' | 'elixir';

This syntax is useful for when you'd rather use literal string values instead of importing and exporting an enum.

We can get more complicated with this sort of union type. Let's say we had some constant data, like so:

const langsAndServers = {
typescript: "fresh",
golang: "gin",
elixir: "phoenix"
} as const;

In this situation, we can create two useful string union types: The first, representing the possible top-level keys of the object, and the second--given the top level key as a type argument--describing the value of the key. We might write something like this:

type Language = keyof typeof langsAndServers;
// type Language = 'typescript' | 'golang' | 'elixir'

type Server<L extends Language> = (typeof langsAndServers)[L];
type Input<L extends Language> = {
lang: L
server: Server<L>
}

function matchProps<L extends Language>(input: Input<L>){
console.log(input)
}

This is great because you can restrict the values of different properties based on the values of others. It's handy when you're building a config-heavy library with lots of options.

// Valid invocation
matchProps({
lang: "typescript",
server: "fresh"
})

// Invalid invocation
matchProps({
lang: "typescript",
server: "gin"
// ^^^^^
// Type '"gin"' is not assignable to type '"fresh"'.
})

The Problem

This setup works great when you're calling the function directly. I ran into an issue where I needed to define an object that satisfied the type of the matchProps argument, as the function would be called with that value elsewhere.

My first instinct was to use the Parameters utility type to create the type I required:

type MatchPropsInput = Parameters<typeof matchProps>[0];
// ^^^^^^^^^^^^^^^
// type MatchPropsInput = {
// lang: "typescript" | "golang" | "elixir";
// server: Server<"typescript" | "golang" | "elixir">;
// }

This is not good. In our type definition, we use a generic type to link the lang and server attributes together. However, when we use the Parameters type, we don't have a way to specify a generic, so TypeScript just replaces the generic with the type it inherited from--in our case, the raw Language type union. This results in code that compiles, but is incorrect:

const badInput: MatchPropsInput = {
lang: "elixir",
server: "fresh"
// ^^^^^^^
// No error
}

matchProps(badInput)
// ^^^^^^^^
// No error

With the little understanding of the TypeScript type system that I had, I was frustrated. After all--I generated this type from the original function definition, so shouldn't I be guaranteed type safety? This is unfortunately not the case.

Solution: Distributive Conditional Types

This is where distributive conditional types come in: Instead of using the Parameters type to generate all of the possible input values, we can use a conditional type to distribute a type with a generic argument across a type union:

type DistributeInput<L extends Language> = L extends any ? Input<L> : never;
type MatchPropsInput = DistributeInput<Language>;
// ^^^^^^^^^^^^^^^
// type MatchPropsInput = Input<"typescript"> | Input<"golang"> | Input<"elixir">

Now, we can define our input data and trust that TypeScript will prevent us from passing invalid data:

const badInput: MatchPropsInput = {
lang: "elixir",
server: "fresh"
// ^^^^^^^
// Type '{ lang: "elixir"; server: "fresh"; }' is not assignable to type 'MatchPropsInput'.
// Type '{ lang: "elixir"; server: "fresh"; }' is not assignable to type 'Input<"elixir">'.
// Types of property 'server' are incompatible.
// Type '"fresh"' is not assignable to type '"phoenix"'.
}

Documentation

There were two places where I sourced information on this feature, the first is a StackOverflow thread , and the second is the official docs for TypeScript's Conditional Types .