Sorry for the difficult title, but please take a look at the following minimal representation:
// We define a couple of symbols
const A: unique symbol = Symbol();
const B: unique symbol = Symbol();
// And create an "enum" of them
const Fs = {
A,
B
} as const;
// This is the type A | B
type F = typeof Fs[keyof typeof Fs];
// This is our "input" types
type Foo = {
[Fs.A]: string,
[Fs.B]: number,
}
// This is our "output" types
type Bar = {
[Fs.A]: number,
[Fs.B]: string,
}
// We define a Mapping for the key (A or B) to be a function taking an
// input of type defined in Foo, and spitting out an output of type defined in Bar
type Mapping<T extends F> = (arg: Foo[T]) => Bar[T];
// We then define a map record as being a list of all such functions
type MapRecord = {
[key in F]: Mapping<key>
}
// Here is the action mapping. It does indeed comply with map record.
const mappings: MapRecord = {
[Fs.A]: (arg: string) => 123,
[Fs.B]: (arg: number) => "asdf",
} as const;
// And here we execute the mapping. Can you spot the error?
function doMapping<T extends F>(t: T, arg: Foo[T]): Bar[T] {
const mapper: Mapping<T> = mappings[t];
return mapper(arg);
}
Seems perfectly fine. But on that const mapper...
line, we actually get the following error:
Type 'Mapping<unique symbol> | Mapping<unique symbol>' is not assignable to type 'Mapping<T>'.
Type 'Mapping<unique symbol>' is not assignable to type 'Mapping<T>'.
Types of parameters 'arg' and 'arg' are incompatible.
Type 'Foo[T]' is not assignable to type 'string'.
Type 'string | number' is not assignable to type 'string'.
Type 'number' is not assignable to type 'string'.
It seems to think that the result of mapping[t]
could be any of the possible records, when we know that it has to be just one.
Why? And how do I convince typescript otherwise?
CodePudding user response:
Few side notes:
First:
const mappings: MapRecord = {
[Fs.A]: (arg: string) => 123,
[Fs.B]: (arg: number) => "asdf",
} as const;
Don't use explicit type MapRecord
with as const
. mappings
will be infered as MapRecord
. as const
does not affect the type of mappings
as all. Hence, you need to apply one or another bot not both.
Second:
The main problem is in this line: mappings[t]
. mappings[t]
returns a union of functions. When you want to call a union of functions, their arguments are intersected. Hence you are getting number & string === never
. Please see the docs:
Likewise, multiple candidates for the same type variable in contra-variant positions causes an intersection type to be inferred:
Please see related answer, article, article
In order to infer mappings an allow it, you should also pass mappings
as an argument. I usually curry
it.
You can use this approach:
// We define a couple of symbols
const A: unique symbol = Symbol();
const B: unique symbol = Symbol();
// And create an "enum" of them
const Fs = {
A,
B
} as const;
// This is our "input" types
type Foo = {
[Fs.A]: string,
[Fs.B]: number,
}
// This is our "output" types
type Bar = {
[Fs.A]: number,
[Fs.B]: string,
}
// Here is the action mapping. It does indeed comply with map record.
const mappings = {
[Fs.A]: (arg: string) => 123,
[Fs.B]: (arg: number) => "asdf",
} as const;
// This is the type A | B
type F = typeof Fs[keyof typeof Fs];
const doMapping = <
Key extends symbol,
Value extends (arg: any) => any, // MAIN DRAWBACK
Mapping extends Record<Key, Value>
>(mapping: Mapping) =>
<
Type extends keyof Mapping,
>(type: Type, arg: Parameters<Mapping[Type]>[0]): ReturnType<Mapping[Type]> =>
mapping[type](arg)
const result = doMapping(mappings)(Fs.A, 's') // number
const result2 = doMapping(mappings)(Fs.B, 42) // number
const error = doMapping(mappings)(Fs.B, 'a') // expected error
Key
- is infered key of main argument
Value
- is infered function
Mapping
- is infered argument.
As you might have noticed it is similar to how we destructure JS objects. In similar
way it is possible to infer them. The algorithm: infer each nested key and value and then assemble them into one data structure.
Type
- infered first argument with appropriate constraint
You can find more information about function arguments inference in my blog
Personally, I think safest approach is just return a function:
// We define a couple of symbols
const A: unique symbol = Symbol();
const B: unique symbol = Symbol();
// And create an "enum" of them
const Fs = {
A,
B
} as const;
// This is our "input" types
type Foo = {
[Fs.A]: string,
[Fs.B]: number,
}
// This is our "output" types
type Bar = {
[Fs.A]: number,
[Fs.B]: string,
}
// Here is the action mapping. It does indeed comply with map record.
const mappings = {
[Fs.A]: (arg: string) => 123,
[Fs.B]: (arg: number) => "asdf",
} as const;
type Mappings = typeof mappings
const doMapping = <Type extends keyof Mappings,>(type: Type) =>
mappings[type]
const result = doMapping(Fs.A) // (arg:string)=>number
Easy and simple.