Reputation: 633
For abstraction purposes I need to implement a function that accept different types of inputs.
type ContentA = string
type ContentB = number
type InputA = {
name: 'method_a'
content: ContentA
}
type InputB = {
name: 'method_b'
content: ContentB
}
type Input = InputA | InputB
Each input is needed for a different method:
const my_methods = {
method_a: (content:ContentA) => {
// ...
},
method_b: (content:ContentB) => {
// ...
}
}
Now I need to implement a generic function that accept all of the inputs, this is because the input types can be a lot, now there are only 2, but in my real application they are around 16.
I would like to have an implementation like this one, however it lead me to a compilation error:
function foo(input:Input){
return my_methods[input.name](input.content);
// ^
// | Argument of type 'string | number' is not
// | assignable to parameter of type 'never'.
// | Type 'string' is not assignable to type 'never'.
}
Is there a way for Typescript to infer that since I am using input.name
then the argument of the method is correct - since they will always match input.name
and input.content
?
Upvotes: 2
Views: 2526
Reputation: 369
This was an interesting challenge, but I've found a nice enough solution that seems to work.
My idea is to take your initial union type Input
and turn everything into generics instead, because narrowing discriminated unions (based on your name
) really only works with literals.
First, let's create a type that has all possible name
values:
type Names = Input["name"];
Next, create a "lookup" generic type that, given the name
as the type argument, gives you the content
type. For example, ContentByName<"method_a">
is ContentA
.
type ContentByName<TName extends Names> = {
[i in Input as i["name"]]: i["content"];
}[TName];
With that, we create a specific type for your my_methods
object. This seems to make it clear enough to the compiler that the names and types types really belong to each other:
type Methods = { [name in Names]: (content: ContentByName<name>) => void };
const my_methods: Methods = { // <-- added to your code here
// ...
}
Finally your foo
function also needs to be generic, for which we also need to create a generic version of the Input
type.
type InputByName<TName extends Names> = {
name: TName;
content: ContentByName<TName>;
};
function foo<TName extends Names>(input: InputByName<TName>) { // <-- added
//...
}
Note that you can happily call this function with a plain ol' Input
like you did before. This is completely valid:
function foo_old(input: Input) {
return foo(input);
}
We didn't actually change anything about the types; we just helped the compiler reason about them.
Here's the playground link with my changes.
Upvotes: 2
Reputation: 182000
I don't think this can be done, because what would the type of my_methods[input.name]
be? It cannot be determined at compile time more strictly than (ContentA) => void | (ContentB) => void
.
So you need a cast:
function foo(input:Input){
return my_methods[input.name](input.content as any);
// ^^^^^^
}
Other answers propose solutions without a cast, which rely on control flow to do type narrowing. Those approaches are also valid, of course, and which is better depends on the situation and on preference.
One improvement you can make if you stick with your current approach, though, is to type my_methods
more strictly, so that the compiler can check that the keys and argument types actually match the possible Input
types:
type InputMethods = {
[I in Input as I['name']]: (content: I['content']) => void
}
const my_methods: InputMethods = {
method_a: (content: ContentA) => {
// ...
},
method_b: (content: ContentB) => {
// ...
}
}
Upvotes: 0
Reputation: 1575
There are different solutions I can propose one of them:
Working playground link
const isOfTypeInputA = (input:Input): input is InputA => {
return input.name === 'method_a';
}
const isOfTypeInputB = (input:Input): input is InputB => {
return input.name === 'method_b';
}
function foo(input:Input){
if (isOfTypeInputA(input)) {
return my_methods.method_a(input.content);
} else if (isOfTypeInputB(input)) {
return my_methods.method_b(input.content);
} else {
throw new Error(`foo Not supported input`);
}
}
Upvotes: 0
Reputation: 19450
First, be aware that the code in the playground is quite different from the one in the question.
The issue with this code is that you're not effectively narrowing the type / one way to do it effectively would be by using a switch statement:
function foo(input: Input) {
switch(input.name) {
case 'method_a': {
// input.content is guaranteed to be of type string
console.log(input.content)
break;
}
case 'method_b': {
// input.content is guaranteed to be of type number
console.log(input.content)
break;
}
}
}
Upvotes: 0