Combining two function types with & (ampersand) in TypeScript (intersection)

August 25, 2023 [Programming, Tech, TypeScript]

Combining interfaces/objects with &

When you combine two types in TypeScript with & (ampersand), it is called an Intersection Type.

For example:

interface Particle {
    mass: number;
}

interface Wave {
    wavelength: number;
}

type Both = Particle & Wave;

The new type Both is both a particle and a wave, so it has both properties mass and wavelength.

If you combined the types with |, making a Union Type, like this:

type Either = Particle | Wave;

then the new type Either would be either a particle or a wave, not both. To use it, you would need to find out which it was, before accessing only the property (mass or wavelength) that you now know exists, and not the other one.

Combining functions with &

This is all fine, and relatively easy to understand, but what about when you combine function types in this way?

type TakesString = (s: string) => void;
type TakesNumber = (n: number) => void;
type TakesStringOrNumber = TakesString & TakesNumber;

Now you have a type that is both TakesString AND TakesNumber, which means a function that can do both things, which means it takes a string or a number as an argument. I can create something that has this type like this:

const f: TakesStringOrNumber = (x: string | number) => {console.log(x);};

Notice how that | symbol sneaked in there? In order to satisfy the & on the function types, the argument types use |. When two things have this kind of opposite relationship it's sometimes known as contravariance.

It has to be this way: for the function to be TakesString AND TakesNumber the argument needs to be string OR number.

Functions with literal types

If your functions take Literal Types this can get even more confusing:

type TakesTrue = (success: true) => void;
type TakesFalse = (success: false) => void;

Any argument you pass to a function that is TakesTrue must be true. Similarly, to call a function that is TakesFalse you must pass in false. TypeScript lets you do this, which is fun.

So now imagine you combine these types:

type TakesEither = TakesTrue & TakesFalse;

Now, TakesEither is both TakesTrue and TakesFalse so it can take in either true or false.

Let's make a function that can do that:

const f: TakesEither = (success: boolean) => { console.log(`${success}`); };

This works - f takes a boolean, so it does indeed allow you to pass in true or false, as required to be a TakesEither.

What's weird though, is that you can't do this:

let x: boolean = new Date().getHours > 12;
f(x); // Compile error

(Ignore the date stuff - x is just a boolean that might be true or false.)

Here is the compile error:

index.ts:9:7 - error TS2769: No overload matches this call.
  Overload 1 of 2, '(success: true): void', gave the following error.
    Argument of type 'boolean' is not assignable to parameter of type 'true'.
  Overload 2 of 2, '(success: false): void', gave the following error.
    Argument of type 'boolean' is not assignable to parameter of type 'false'.

The thing is that the compiler only knows that f is a TakesEither, which means that it is either a function that takes true or a function that takes false. It doesn't know that f can take a boolean (even though here it can!).

This code does work:

let x: boolean = new Date().getHours() > 12;
if (x) {
    f(x); // OK
} else {
    f(x); // OK
}

Why? Because inside the first part of the if, the compiler knows that x is true, so it can call f with it, because f can take true as an argument. Similarly in the second half, it knows x is false.

In future, the compiler might grow the ability to handle work in the first case, but at time of writing, we see this error.

Cool, huh?

(Thanks to jcalz for this stackoverflow answer that helped me understand this a little: typescript intersection types of function.)