Search code examples
typescriptdiscriminated-uniontypescript-types

Taking complements of discriminated unions


Let's say I have the following discriminated unions and some associated types

type Union = 'a' | 'b';
type Product<A extends Union, B> = { f1: A, f2: B};
type ProductUnion = Product<'a', 0> | Product<'b', 1>;

Now I can take complements by using mapping types and Exclude

type UnionComplement = {
  [K in Union]: Exclude<Union, K>
};
// {a: "b"; b: "a"}

type UnionComplementComplement = {
  [K in Union]: Exclude<Union, Exclude<Union, K>>
};
// {a: "a"; b: "b"}

So far all of this makes sense but things break down for ProductUnion when I try to take the double complement. The first complement works fine

type ProductComplement = {
  [K in Union]: Exclude<ProductUnion, { f1: K }>
};
// {a: Product<'b', 1>; b: Product<'a', 0>}

The double complement is incorrect no matter what I try

type ProductComplementComplement = {
  [K in Union]: Exclude<ProductUnion, Exclude<ProductUnion, { f1: K }>>
};
// {a: ProductUnion; b: ProductUnion}

I don't understand where the bug is because if I substitute the types then it should work. There are only 2 values for K when taking the double complement so let's try the first one

type First = Exclude<ProductUnion, Exclude<ProductUnion, { f1: 'a' }>>;
// {f1: 'a'; f2: 0}

Second one also work

type Second = Exclude<ProductUnion, Exclude<ProductUnion, { f1: 'b' }>>;
// {f1: 'b'; f2: 1}

All the constituent parts work but when combined in the mapping type it seems to break down. What am I missing here?

On a whim I tried adding a type parameter to see what would happen by abstracting the complementing process

type Complementor<T> = {
    [K in Union]: Exclude<T, { f1: K }>
};

type DoubleComplementor<T> = {
    [K in Union]: Exclude<T, Exclude<T, { f1: K }>>
};

Now if I apply the parametrized types to ProductUnion it works exactly as I expect

type Complement = Complementor<ProductUnion>;
// {a: Product<'b', 1>; b: Product<'a', 0>}

type DoubleComplement = DoubleComplementor<ProductUnion>;
// {a: Product<'a', 0>; b: Product<'b', 0>}

Solution

  • This was indeed a bug: https://github.com/Microsoft/TypeScript/issues/28824. Thanks to Anders and team the next release should have more consistent behavior.