Search code examples
typescriptdesign-patterns

How to avoid switch cases in typescript when switching over discriminators?


I want to refactor the following code in a way, that gets rids of the switch case, but preserves type safety. I've found me in multiple situations where this problem occurred. Is there a pattern to use a map or something else than a switch case?

function translate(when: When): SomeCommonResultType {
  switch (when.object) {
    case "timespan":
      return translateTimeSpan(when);
    case "time":
      return translateTime(when);
    case "datespan":
      return translateDateSpan(when);
    case "date":
      return translateDate(when);
  }
}

type When = Timespan | Time | DateSpan | Date;

export interface Timespan {
  readonly object: "timespan";
  startTime: number;
  endTime: number;
  startTimezone?: string;
  endTimezone?: string;
}

function translateTimeSpan(when: Timespan): SomeCommonResultType {
  ...
}

export interface Time {
  readonly object: "time";
  time: number;
  timezone: string;
}

function translateTime(when: Time): SomeCommonResultType {
  ...
}

export interface DateSpan {
  readonly object: "datespan";
  startDate: string;
  endDate: string;
}

function translateDateSpan(when: DateSpan): SomeCommonResultType {
  ...
}

export interface Date {
  readonly object: "date";
  date: string;
}

function translateDate(when: Date): SomeCommonResultType {
  ...
}

Normaly I would do something like the following, but I really don't want to use 'any'

const TranslateWhenMap = new Map<string, any>([
  ["timespan", translateTimeSpan],
  ["date", translateDate],
  ["datespan", translateDateSpan],
  ["time", translateTime],
]);

function translate(when: When): SomeCommonResultType {
  return TranslateWhenMap.get(when.object)(when);
}

Solution

  • The TypeScript compiler can only really type check a block of code once; it can't look at TranslateWhenMap.get(when.object)(when) where when is of a union type and understand that when will be the appropriate argument to the TranslateWhenMap.get(when.object) function, even if a Map had strongly typed key-value relationships (which it doesn't, see Typescript: How can I make entries in an ES6 Map based on an object key/value type ). It can't enumerate the possible types of when and check each one separately, like "if when is a TimeSpan then it's fine; if date is a TimeSpan then it's fine; etc". It does it "all at once" and will get confused. This is the subject of microsoft/TypeScript#30581.

    The only way to get type checking for multiple cases in a single block of code is to refactor to make your translate() function generic, and write the operations such that the compiler sees that the generic parameter type to the looked-up up function is the same generic type as when. The general approach to refactoring for situations like this is described in microsoft/TypeScript#47109, although the case here doesn't quite need all of the machinery listed there.

    Here's how I'd approach your example:

    type WhenObject = When["object"];
    type TranslateArg<K extends WhenObject> = Extract<When, { object: K }>;
    
    const translateWhenMap: { [K in WhenObject]: 
      (arg: TranslateArg<K>) => SomeCommonResultType 
    } = {
      timespan: translateTimeSpan,
      date: translateDate,
      datespan: translateDateSpan,
      time: translateTime
    }
    
    function translate<K extends WhenObject>(when: TranslateArg<K>) {
      const obj: K = when.object;
      return translateWhenMap[obj](when);
    }
    

    The WhenObject utility type is just the union of object property types of When, implemented as an indexed access type. And the TranslateArg<K> utility type evaluates to the union member of When whose object property matches K, implemented via the TS-provided Extract utility type.

    I've then replaced your Map with a plain strongly-typed translateWhenMap object, where the compiler knows the relationship between each key and value. (e.g., the timespan key has a function expecting a Timespan argument, etc.) And I explicitly annotated that object as being written in terms of a mapped type over WhenObject. This explicit type is important because it allows the function lookup to remain generic in K.

    Finally, the translate() function is generic in K constrained to WhenObject, and when is of type TranslateArg<K>. Then inside the function I widen when.object to the generic type K (left on its own, the compiler will see it as equivalent to K & WhenObject which confuses it) and assign to obj. And then the call translateWhenMap[obj](when) compiles without error. transalteWhenMap[obj] is seen as being of type (arg: TranslateArg<K>) => SomeCommonResultType, and since when is of type TranslateArg<K>, the call succeeds.


    That's about as type safe as it's possible to get here. If you modify the entries of translateWhenMap or the implementation of translate, you should get errors telling you what you did wrong:

    const badTranslateWhenMap: { [K in WhenObject]: (arg: TranslateArg<K>) => SomeCommonResultType } = {
      timespan: translateDateSpan, // error
      date: translateDate,
      datespan: translateTimeSpan, // error
      time: translateTime
    }
    
    function badTranslate<K extends WhenObject>(when: TranslateArg<K>) {
      const obj: K = when.object;
      const badWhen: Date = { object: "date", date: "Next Thursday" };
      return translateWhenMap[obj](badWhen); // error!
    }
    

    Playground link to code