First, TypeScript doesn't by default recognize that a Map
could be strongly typed so that each key corresponds to a different value type. A Map<K, V>
is like Record<K, V>
in that it treats all values as the same type. There are ways to try to encode different value types for Map
in the type system (see Typescript: How can I make entries in an ES6 Map based on an object key/value type) but it's much, much easier to just use a plain object, since object types naturally represent such key-value type pairs. So let's make letterMap
as a plain object like
const letterMap = {
alpha: cfg => cfg.alpha,
beta: cfg => cfg.beta
}
and work on how to use it the way you'd like.
TypeScript doesn't really support the concept of "correlated unions". If you have a value cfg
of a discriminated union type Letters
, and look up cfg.name
in your letterMap
map you will get a union of functions. And then if you try to call that union of functions by passing it cfg
as an argument, the compiler will be unhappy. It doesn't understand that the union of functions and the union of arguments are correlated to each other such that the function and argument are appropriate for each other. Instead it treats them as independent unions, and therefore complains that you might be calling calculateAlpha
with an Beta
argument, or calculateBeta
with an Alpha
argument. See microsoft/TypeScript#30581 for more information.
The recommended approach to deal with correlation unions, as described in microsoft/TypeScript#47109, is to refactor to replace the union with generics. The technique involves encoding the underlying relationship you need the compiler to track as a base interface, and then writing operations on this type as generic indexes into this interface or mapped types over that interface.
In your case, the base interface looks like this:
interface LetterMapping {
alpha: { alpha: 1 },
beta: { beta: 2 }
}
And then we can redefine your Letters
type as a mapped type over it:
type Letter<K extends keyof LetterMapping = keyof LetterMapping> =
{ [P in K]: { name: P } & LetterMapping[P] }[K]
This is a distributive object type, so Letter<"alpha">
and Letter<"beta">
correspond to your original Alpha
and Beta
types:
type Alpha = Letter<"alpha">;
type Beta = Letter<"beta">;
and Letter<"alpha" | "beta">
corresponds to your original Letters
discriminated union type (and we can just write Letter
for that, since "alpha" | "beta"
is the default type argument).
Now letterMap
's type needs to be explicitly annotated as another mapped type over LetterMapping
:
const letterMap: { [K in keyof LetterMapping]:
(cfg: Letter<K>) => number } = {
alpha: cfg => cfg.alpha,
beta: cfg => cfg.beta
}
And now we can write calculatorFactory
as a generic function like this:
const calculatorFactory = <K extends keyof LetterMapping>(
cfg: Letter<K>
) => letterMap[cfg.name](cfg); // okay
Here the compiler sees cfg.name
as having the non-union generic type K
. And because letterMap
's annotated type is a mapped type over the base interface, when we look up letterMap[cfg.name]
we get the non-union generic type (cfg: Letter<K>) => number
. And this function expects an argument of type Letter<K>
, which is exactly the type of cfg
. So letterMap[cfg.name](cfg)
compiles with no error, and is seen to be of type number
, as desired.
And callers will also see reasonable behavior:
calculatorFactory({ name: "alpha", alpha: 1 });
/* <"alpha">(cfg: { name: "alpha"; } & { alpha: 1; }) => number */
calculatorFactory({ name: "beta", beta: 2 });
/* <"beta">(cfg: { name: "beta"; } & { beta: 2; }) => number */
Here the compiler is able to infer that K
is "alpha"
for the first call and "beta"
for the second. They both return number
, so in some sense it doesn't matter, but you could imagine changing the functions so that the return type was also looked up in a base interface:
interface LetterReturn {
alpha: boolean,
beta: string
}
const letterMap: { [K in keyof LetterMapping]:
(cfg: Letter<K>) => LetterReturn[K] } = {
alpha: cfg => Math.random() < (cfg.alpha / 2),
beta: cfg => cfg.beta.toFixed(2)
}
const a = calculatorFactory({ name: "alpha", alpha: 1 });
/* const a: boolean */
const b = calculatorFactory({ name: "beta", beta: 2 });
/* const b: string */
which is nice.
Playground link to code