don
don

Reputation: 4532

Index a Interface with a const enum and infer type in a switch statement in TypeScript

I am trying to automatically infer the type of some data passed in a switch statement based on the enum value fulfilling the case condition.

To achieve this I've defined a const enum:

const enum MESSAGES {
  open = 1,
  close,
  redo
}

Then I use it to index a Interface:

interface MessagePayloadContent {
  [MESSAGES.open]: string;
  [MESSAGES.close]: number;
  [MESSAGES.redo]: boolean;
}

At this point I define the object that I will evaluate in the switch statement:

interface MessagePayload<T extends MESSAGES> {
  scope: T;
  content: MessagePayloadContent[T];
}

Finally I use the above in a switch statement.

I would expect that the interpreter can infer, based on the value being passed to each case, what type of data will be contained in the object.

On the contrary I get the errors as in the comments in the code below:

function pick(payload: MessagePayload<MESSAGES>): void {
  switch (payload.scope) {
    case MESSAGES.open:
      open(payload.content); // Argument of type 'string | number | boolean' is not assignable to parameter of type 'string'.
      break;
    case MESSAGES.close:
      close(payload.content as number); // This is my current workaround.
      break;
    case MESSAGES.redo:
      redo(payload.content); // Argument of type 'string | number | boolean' is not assignable to parameter of type 'boolean'.
      break;
  }
}

const open = (d: string) => d;
const close = (d: number) => d;
const redo = (d: MessagePayloadContent[MESSAGES.redo]) => d;

What I don't fully get is that in some other circumstances this does work, so I wonder why some other times it does not.

Upvotes: 1

Views: 471

Answers (1)

jcalz
jcalz

Reputation: 329608

The problem here is that the type MessagePayload<MESSAGES> is not what you think it is. Interfaces don't generally distribute over unions, so it just evaluates to:

interface OopsMessagePayload {
  scope: MESSAGES;
  content: string | number | boolean;
}

Which means that if you check the scope property, it does not narrow the type of the content property.

TypeScript does have some type-level constructs that distribute over unions, so there should be a way to define

type MessagePayloadDistributive<T> = ...

such that

MessagePayloadDistributive<MESSAGES> 

evaluates to

MessagePayload<MESSAGES.open> |
MessagePayload<MESSAGES.closed> |
MessagePayload<MESSAGES.redo> 

I will use distributive conditional types in which if you have a type like type D<T> = T extends U ? V : W where the checked type T is a bare type parameter, the conditional check will be distributed across unions:

type MessagePayloadDistributive<T extends MESSAGES> = T extends any
  ? MessagePayload<T>
  : never;

type SomeMessagePayload = MessagePayloadDistributive<MESSAGES>;

If you inspect that, SomeMessagePayload evaluates to the desired type above. And then the following code will work as you expect:

function pick(payload: SomeMessagePayload): void {
  switch (payload.scope) {
    case MESSAGES.open:
      open(payload.content); // okay
      break;
    case MESSAGES.close:
      close(payload.content); // okay
      break;
    case MESSAGES.redo:
      redo(payload.content); // okay
      break;
  }
}

Okay, hope that helps. Good luck!

Link to code

Upvotes: 2

Related Questions