Enforcing Disjoint Unions with TypeScript Conditional Types

TypeScript 2.8 introduced the conditional type operator, which allows for building comprehensive and powerful types. One of the more useful ones I’ve found is a “disjoint” type, which ensures that there are no elements in common between two sets or objects. This could also be referred to as “mutual exclusion.”

The basic operators in the TypeScript type system include logical unions and intersections, which gives us the tools we need to build more complicated algebraic types.

Throughout the following examples, I’m going to be using these simple types:

interface A {
    x: number;
    y: number;
    z: number;
}

interface B {
    x: number;
    y: number;
}
interface C {
    z: number;
}
interface D {
    x1: number;
    x2: number;
}

const a: A = { x: 1, y: 2, z: 3 };
const b: B = { x: 1, y: 2 };
const c: C = { z: 3 };
const d: D = { x1: 1, x2: 2 };

The Disjoint type looks like this:

type Disjoint <T1, T2> = Extract<keyof T1, keyof T2> extends never ? T2 : never;

type DisjointTest1 = Disjoint<A, B>; // never
type DisjointTest2 = Disjoint<B, C>; // C
type DisjointTest3 = Disjoint<A, C>; // never

I like using Extract<keyof ...> here because this method doesn’t care about the type of the property. It treats two differently typed properties with the same key as an error. To break this down a little more:

Extract<keyof A, keyof B>; // "x" | "y"
Extract<keyof B, keyof C>; // never
Extract<keyof A, keyof C>; // "z"
Extract<keyof A, keyof B> extends never ? "mutually exclusive" : "overlap"; // "overlap"
Extract<keyof B, keyof C> extends never ? "mutually exclusive" : "overlap"; // "mutually exclusive"

There are many use cases for enforcing mutual exclusion between two objects. Most frequently, these would show up in generic function parameter declaration.

I’d like to create a function that accepts generic parameters and ensures that there is no overlap between the two objects passed in. My first implementation, using the Disjoint type we declared above, looks like this:

function merge<
  T1 extends {},
  T2 extends Disjoint<T1, T2>
  > (a: T1, b: T2): T1 & T2 {
    return Object.assign({}, a, b);
}

But unfortunately, this produces a TypeScript error, which indicates that the type declaration has a circular constraint.

Circular constraints in generic types

The problem is that the Disjoint type is equivalent to T2 extends Extract<keyof T1, keyof T2> extends never ? T2 : never, which could resolve to T2 extends T2.

We’ll need to tweak the type definition to use this in a generic function:

function merge<
  T1 extends {},
  T2 extends Extract<keyof T1, keyof T2> extends never ? {} : never,
  > (a: T1, b: T2): T1 & T2 {
    return Object.assign({}, a, b);
}

Notice the slight difference here. The updated version can resolve to T2 extends {} or T2 extends never, which should do the trick. Now, the compiler or the text editor can indicate if there are any invalid values being passed in.

Correct behavior of generic types