47ndr
47ndr

Reputation: 633

Typescript implementing a generic function that accept multiple type of inputs

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?

Playground link

Upvotes: 2

Views: 2526

Answers (4)

ij7
ij7

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

Thomas
Thomas

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) => {
        // ...
    }
    
}

Playground

Upvotes: 0

Svetoslav Petkov
Svetoslav Petkov

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

Agos
Agos

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

Related Questions