typescriptexhaustive

Creating union type to ensure each value of `enum` is handled


I have an enum of operations (that I can't change, unfortunately):

enum OpType {
  OpA = 0,
  OpB = 1,
}

…and a set of types for objects that carry the data needed for each operation:

type A = {
  readonly opType: OpType.OpA;
  readonly foo: number;
};

type B = {
  readonly opType: OpType.OpB;
  readonly bar: string;
};

…and finally a handler function that ensure that each operation is handled:

type Ops = A | B;

export const ensureExhaustive = (_param: never) => {};

export const handleOp = (op: Ops) => {
  switch (op.opType) {
    case OpType.OpA:
      if (op.foo < 80) { /* … */ }
      break;
    case OpType.OpB:
      if (op.bar === 'foo') { /* … */ }
      break;
    default:
      ensureExhaustive(op);
  }
}

However, this handleOp function only really assures that we handle what was explicitly added to the Ops union – the connection to the OpType enum has been lost, so if a OpC = 2 is added to the enum, it won't be detected that this isn't handled.

How can I “connect” the enum values (probably through an Ops type) to the switch statement in handleOp to ensure that each value is handled?


Solution

  • You could just recreate the ensureExhaustive function as a type:

    type EnsureExhaustive<T extends never> = T;
    

    Then you could define a dummy type that just makes sure something is never:

    type OpsMatchesEnum = EnsureExhaustive<Exclude<OpType, Ops["opType"]>>;
    

    Conveniently, there exists a utility Exclude that already has the behavior we want:

    Exclude<1 | 2 | 3, 1 | 2>;     // 3
    Exclude<1 | 2 | 3, 1 | 2 | 3>; // never
    

    In other words, the result of this type is never if the second argument encompasses the first. Translating to our use case, we want this to be never if Ops["opType"] uses all members of OpType.

    Another trick we can do is to make ensureExhaustive generic, so that it would have the signature

    export const ensureExhaustive = <T extends never>(_param: T) => {};
    

    so we can then utilize instantiation expressions that were introduced in 4.7, removing the need for an EnsureExhaustive type:

    type OpsMatchesEnum = typeof ensureExhaustive<Exclude<OpType, Ops["opType"]>>;
    

    Of course, if you have noUnusedLocals enabled or a linter, this dummy type might cause an error or warning.