0

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);
}
Bosse
  • 81
  • 1
  • 6
  • Does [this approach](https://tsplay.dev/NaDRnw) meet your needs? If so I'll write up an answer explaining; if not, what am I missing? – jcalz May 19 '23 at 13:30
  • Yes! Thank you, this matches my expectations. Nontheless I don't think the solution is practical for evey day usage. But I don't see a way around the complexety without building a small function wrapping away the complexety... – Bosse May 19 '23 at 14:39
  • I don't understand, how is my suggestion any more impractical than the version you wrote with "normally I would do something like the following"? It's almost the same. I'm happy to write up an answer but I'm confused about the issue around practicality/complexity. – jcalz May 19 '23 at 14:46
  • 1
    I understand your confusion and I don't mean that your answer is bad in any way, it's great. The Pattern that is needed to make the approach type safe is just, way more complicated to learn, apply and recognize than the factory or builder pattern. I am actually trying to wrap this stuff into a creator function, but I'm not the biggest fan of introducing such "framework-functions". I think an _excellent_ pattern should just use the built in tools of the language. So this is complaining on a high level. – Bosse May 19 '23 at 18:04

1 Answers1

2

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

jcalz
  • 264,269
  • 27
  • 359
  • 360