How TypeScript distributes unions

In the past few weeks I've been writing a little library in TypeScript, and I've learned a lot about the type system in the process. This is the first article, which focuses on how TypeScript deals with unions.

Does this typecheck? And if it does, what is the type of x?

function foo(it: string): string
function foo(it: number): number
function foo(it: number | string): number | string { return it }
declare const it: number | string
const x = foo(it)

What about this?

class Foo {
    foo(): string { return "1" }
}
class Bar {
    foo(): number { return 1 }
}
declare const r: Foo | Bar
const x = r.foo()

Final question, in the following snippet does x come out as Foo<number | string> or Foo<number> | Foo<string>?

class Foo<A> {
    constructor (readonly value: A) {}
    copy(): Foo<A> { return new Foo(this.value) }
}
declare const r: Foo<string> | Foo<number>
const x = r.copy()

The first example doesn't compile, the second does. This will explain why, and how we can create a functional signature equivalent to that of the first example.

The third example comes out as Foo<number> | Foo<string> (which is assignable to Foo<number | string>). This is what we want! However, we will see how to can force the function to output Foo<number | string>, because that is sometimes convenient.

This is mostly a reference piece for the language nerds amongst you. But if you're curious, let's dive in.

Overload Distributivity

Let's take our motivating example:

function foo(it: string): string
function foo(it: number): number
function foo(it: number | string): number | string { return it }
declare const it: number | string
const x = foo(it) // TS2769: No overload matches this call. 

foo is an overloaded function. These work differently in TypeScript than in traditional statically-typed languages like Java. In Java, each overload would be its own distinct function, with its own implementation, that just happen to share a name. In TypeScript, there is a single implementation with multiple signatures.

TypeScript could do what Java does. It could compile each overload to a JavaScript function with a different name and statically assign each call site to the proper overload based on type information.

The reason is doesn't is probably twofold: first, this system allows writing multiple type signatures for a single existing JavaScript function. Second, TypeScript generally shies away from complex code generation: the TypeScript compiler mostly just checks types, then strips them. (There are exceptions, for instance TypeScript enum do generate code, albeit the generation is local and doesn't affect the call site.)

In any case, our argument has type number | string and quite clearly, allowing the call and typing x as number | string is correct.

When matching function arguments, TypeScript simply doesn't distribute signatures over unions.

However, that doesn't hold for method receivers (x in x.foo()). The next section will cover that, the section after that covers some necessary background, and then we'll go on to show how we can actually achieve this overloaded signature!

Receiver Distributivity

For receivers, the logic is more complicated. The basic idea is that TypeScript tries to find matching signatures across receivers, and that match does not include the return type, so distribution is possible!

Therefore our example works and types x as string | number:

class Foo {
    foo(): string { return "1" }
}
class Bar {
    foo(): number { return 1 }
}
declare const r: Foo | Bar
const x = r.foo()

Now let's unpack the exact process.

When calling a method on a union receiver, TypeScript collects the method definitions on both sides. It instantiates the class type parameters (if any) with the concrete type arguments. It then tries to build a set of unified signatures, basically trying to match every signature on every branch with another signature on all the other branches. If no match can be found across all branches of the union, the signature is dropped.

Signatures match if:

Each matching set produces a unified signature where the return type is the union of the return type of all signatures in the set.

The resulting set of unified signatures is then matched against the arguments. If nothing match you get a "TS2769: No overload matches this call.".

If no unified signatures are produced, TypeScript falls back to a secondary algorithm, but only if at most one branch has overloads.

In that case, it takes every overload (or just a singular signature if no branch has overloads) and produces unified signatures against the (singular) signature in the other branches. These unified signatures are different from the ones in the initial approach: the parameter types become intersection types of the types across branches. The return type is a union type like before. More precisely: types in covariant positions are union-ed, while types in contravariant position are intersected (don't worry to much if you don't know what that means). If present, an explicit this parameter is treated as any other parameter.

In the secondary algorithm, the signatures being unified can have a different number of parameters. Shorter parameter lists don't contribute typing info to the parameters they don't have.

The handling of type parameter is also more relaxed: one side is allowed to have a parameter list if the other sides don't. Otherwise the signatures have to be compatible, excepted that they may differ in their defaults. The default (or absence of default!) from one branch of the union is always applied. If other branches had defaults, those are always dropped. The selected branch is non-deterministic! (It depends on non-guaranteed loading logic.)

If the set of signatures produced by the second algorithm is also empty, you can get the following errors:

And if the signature set not empty but it can't match the parameters to any signature, you will get:

This is not always safe.

If multiple overloads match the arguments, they are normally prioritized in the order in which they appear. The signature matching process can however cause some signatures to disappear.

Consider the following example, where we can imagine that Foo.foo is a function to process parameters, where strings are automatically parsed as numbers:

class Foo {
    foo(x: string): number
    foo<A>(it: A): A
    foo<A>(it: A | string): A | number {
        return typeof it === "string" ? Number.parseInt(it) : it
    }
}
class Bar {
    foo<A>(it: A): A { return it }
}
declare const r: Foo | Bar
const x = r.foo("42") // x: "42"

Here x will type as "42", because the first foo signature disappears in the matching process. The implementation, however, remains the same. This means that if r is assigned an instance of Foo, then x will contain 42, but type as "42", a type soundness issue.

Conditional Distributivity

Before we get into the method we can use to achieve almost arbitrary "overloaded" signatures, we need some background on type conditionals (i.e. type-level expression of the form A extends B ? C : D).

Type conditionals distribute on unions if the left-hand side of the condition is naked. They don't distribute if it is wrapped. The common way to prevent distribution is to wrap in a tuple.

type Foo<T> = T extends "a" ? "A" : T extends "b" ? "B" : "C"
type Test = Foo<"a" | "b" | "c"> // "A" | "B" | "C"

type Foo2<T> = [T] extends ["a"] ? "A" : [T] extends ["b"] ? "B" : "C"
type Test2 = Foo2<"a" | "b" | "c"> // "C"

The example should be self-explanatory: in Foo1 the union distributes and each lowercase string type maps to the corresponding uppercase string type. In Foo2, T is the entire union, which falls through the default case ("C").

never in conditionals

never with conditionals behaves like an empty union. Distributing never on a conditional yields never.

type TestNever = Foo<never> // never
type TestNever2 = Foo2<never> // "A"

"Overloads" via Generic Signatures

What overloads provide is the ability to define multiple parameter lists for a single function, and to map each parameter list to a return type.

With a few tricks, we can achieve a lot of this, and allow distribution over unions.

The method I will present is well suited when parameter lists are relatively homogenous: each parameter has one or multiple possible types, and all combinations are valid. It works poorly with very heterogenous parameter lists, where overloads should stil be used (and union distribution is probably not applicable anyway).

To mimic overloads with generic (using a single-parameter function as example), we will need:

Given our headline example:

function foo(it: string): string
function foo(it: number): number
function foo(it: number | string): number | string { return it }
declare const it: number | string
const x = foo(it) // TS2769: No overload matches this call. 

The solution looks like this:

type FooReturn<T extends number | string> = T extends number ? number : string
function foo<T extends number | string>(it: T): FooReturn<T> { return it as FooReturn<T> }
declare const it: number | string
const x = foo(it) // number | string
const y = foo(42) // number

Maybe you've noticed this is overcomplicated here, and the return type could simply have been T. This is indeed correct here, but in every case where the parameter type and return type differ, you will need the conditional type.

Notice that sadly, TypeScript will be unable to figure out that the return type matches the bound, so a cast will be needed.

How does this handle unions? Simple: remember that unions distribute on type conditionals, so FooReturn maps each branch of the union separately.

Squashing Homogeneous Unions with Extractor Types

A particularly interesting class of unions are those where each branch is a differently-parameterized version of the same type.

Consider the example from the intro:

class Foo<T> {
    constructor (readonly value: T) {}
    copy(): Foo<T> { return new Foo(this.value) }
}
declare const r: Foo<string> | Foo<number>
const x = r.copy() // types as `Foo<string> | Foo<number>`
const y: Foo<string | number> = r.copy()

Up to the x assignment, nothing is terribly novel here: we've seen that typescript can distribute methods over receivers.

The y assignment is interesting: whenever T is a covariant type parameter, it is always true that Foo<A> | Foo<B> is assignable to Foo<A | B>.

"Foo is covariant in T" means that if B is assignable to A, then Foo<B> is assignable to Foo<A>. This is the case whenever T only appears in "output" position in T (in return types, but not parameter types).

In reality, TypeScript is very loose here and treats a lot of things as covariant when doing so isn't type-safe, for the sake of legacy compatibility. A full treatement of covariance, contravariance and invariance in TypeScript would be its own article — a quick definition of covariance will suffice here.

Note that while Foo<A> | Foo<B> is assignable to Foo<A | B> the reverse isn't true. This is because this is generally unsafe (albeit it isn't in this specific definition of Foo): Foo<A> | Foo<B> refers to things that either always output A or always output B, while Foo<A | B> refers to things that can output a mix or A or B.

Now as we've seen before, unions are not always distributed over, making them sometimes inconvenient. They also arise naturally in the code via multiple return statements and conditionals (condition ? new Foo(42) : new Foo("AH")).

While we can always coerce to Foo<A | B> via a cast or a type bound, it is sometimes convenient to perform this coercion as part of a method. I encountered the situation while writing a small result library for TypeScript:

const x = condition ? okay(42) : fail(Error("error!")) // Result<number, never> | Result<never, Error>
const y = x.map(it => it + 69)

We'd like y to have the "nice" type Result<number, Error>, meaning it either holds a number or an error, instead of Result<number, never> | Result<never, Error>, which means the same thing, but is less readable.

Here's how you do it:

type GetT<F extends Foo<unknown>> = F extends Foo<infer T> ? T : never
class Foo<T> {
    constructor (readonly value: T) {}
    copy<This extends Foo<unknown>>(this: This): Foo<GetT<This>> { return new Foo(this.value as GetT<This>) }
}
declare const r: Foo<string> | Foo<number>
const x = r.copy() // types as `Foo<string | number>`

We capture the type of the receiver in This (and in this example, it's a union). Then we use the extractor type GetT to recover the value of T. Because unions distribute over type conditionals, GetT<This> yields string | number, and we get what we want.

Summary

There you have it. You now know:

I hope it's been helpful, until next time!