1

I have a bunch of interfaces with one common field, used as the discriminator for disjoint union. This field is composed of several enums used elsewhere, so I can't make it a single enum in a reasonable way. Something like this (simplified):

const enum Enum1 { key1 = 'key1', key2 = 'key2' }
const enum Enum2 { key1 = 'key1', key3 = 'key3' }
interface Item1 { key: Enum1; value: string; }
interface Item2 { key: Enum2; value: number; }
type Union = Item1 | Item2;

The type is used like this:

let item: Union = getUnionValue();
switch (item.key) {
    case Enum1.key1:
        // do something knowing the 'item.value' is 'string'
        break;
    // some other cases
    case Enum2.key1:
        // do something knowing the 'item.value' is 'number'
        break;
    // some more cases
}

Of course, when keys in different enums are equivalent, this will lead to breakage at runtime.

Is there any way to check whether the discriminator type Union['key'] is in fact disjoint, i.e. if all the types used are non-intersecting? In other words, I'm looking for the code which would error on the type above, signaling that Enum1.key1 clashes with Enum2.key1.

I've tried the following:

type Checker<T> = T extends any ?
 (Exclude<Union, T>['key'] extends Extract<Union, T>['key'] ? never : any)
 : never;
const test: Checker<Union> = null;

hoping to make use of distribution over conditional types, but this doesn't seem to work.

Cerberus
  • 8,879
  • 1
  • 25
  • 40

2 Answers2

1

Here's one solution using IsUnion, which in turn relies on UnionToIntersection:

type UnionToIntersection<U> =
    (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never
type IsUnion<T> =
    [T] extends [UnionToIntersection<T>] ? false : true
type DisjointUnion<T extends { key: string }> =
    { [k in T["key"]]: IsUnion<Extract<T, { key: k }>> } extends { [k in T["key"]]: false } ? T : never;

Using a union with distinct key values is fine:

type Union = DisjointUnion<Item1 | Item2>;         // Item1 | Item2

But add an item with an already existing key and the resulting type will be never:

type Union = DisjointUnion<Item1 | Item2 | Item3>; // never
p.s.w.g
  • 146,324
  • 30
  • 291
  • 331
  • Very clever, thanks. But it seems that my use case is harder to work with due to enum (not only literal) keys, which were mentioned in words, but simplified out in code. I'll update the question in a moment. – Cerberus Feb 28 '19 at 02:52
  • @Cerberus I'm not sure it can be done with `enum`'s since they don't play as nicely with the type operators (e.g. `Enum1 & Enum2` evaluates to `never` rather than `"key1"`). I'm still looking at it to see if it can be done with multiple unions. – p.s.w.g Feb 28 '19 at 18:31
0

One possibility involves building the union based off a map/object type, so that it's impossible to add something with conflicting keys:

interface Item1 { key: 'key1'; value: string; }
interface Item2 { key: 'key2'; value: number; }
interface Item3 { key: 'key1'; value: Object; }

type UnionAsMap = {
    'key1': Item1,
    'key2': Item2
};
type Union = UnionAsMap[keyof UnionAsMap]; // Equivalent to Item1 | Item2

This then presents the problem that you might accidentally set the wrong key, but we can use this bit of code to enforce that they keys match up:

type UnionWithMatchingKeys = { [K in keyof UnionAsMap]: UnionAsMap[K] extends { key: K } ? UnionAsMap[K] : never };
type UnionHasMatchingKeys = Union extends UnionWithMatchingKeys[keyof UnionWithMatchingKeys] ? true : false;
let unionCheckSuccess: UnionHasMatchingKeys = true;

So that if you accidentally did something like this:

interface Item1 { key: 'key3'; value: string; }

UnionHasMatchingKeys will end up as false, and the unionCheckSuccess line will present an error.

Tyler Church
  • 649
  • 5
  • 8