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);
}
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!
}