2

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

3
  • 2
    Have you considered making InputA and InputB into classes, so you can simply add an overridable method onto them? I appreciate that this is not always possible, e.g. if they are parsed from JSON. Commented Oct 20, 2022 at 8:56
  • 2
    This can definitely be done; see ms/TS#30581 for the feature request (I called it "correlated unions") and ms/TS#47109 for the fix. If I follow those directions with your code I get this playground link. If that meets your needs I would be happy to write up an answer explaining it. If not, what am I missing? (In either case, please mention @jcalz in your comment to notify me) Commented Oct 21, 2022 at 1:21
  • Ah, I would write up the answer but it looks like @ij7's answer uses essentially the same technique. Hopefully they will add in links to the GitHub issues above. Commented Oct 22, 2022 at 2:02

4 Answers 4

2

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.

Sign up to request clarification or add additional context in comments.

4 Comments

This is essentially the right answer, although you might want to link to the canonical documentation for it: microsoft/TypeScript#30581 which reported the initial problem with correlated unions, and microsoft/TypeScript#47109 which implemented some changes to accommodate using generics.
Thank you @ij7. Everything is clear. Just one thing. What exactly is happening when you define ContentByName? Why using i in Input as i["name"] instead of simple i in Input. Also why using at the end {}[TName]?
@47ndr In the first step, it's creating a type that represents a mapping from name to content type, i.e. { method_a: ContentA, ... }. The as i["name"]is there because a property always has to be string, number, or symbol. In the second step, via {}[TName] we grab the entry from the type that corresponds to the type argument that we're looking for. You can think of the whole thing as the TS type version of .map(...).find(...).
@jcalz Thanks for those links! I hadn't seen those yet. I wish the typescript team would migrate some of the great content they have in their PR discussions into the official documentation.
0

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

1 Comment

thanks for pointing out the playground link wasn't updated like the question. However, for your answer, I would like to avoid a switch since there might be a lot of cases.
0

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

Comments

0

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

5 Comments

I tried your improvement but without any success. I don't know how to use the your index signature. Maybe you mean something like this Playground link ? But it still doesn't solve the issue.
Playground link is broken, links to an entirely different website. Added a playground link to the answer.
Thank, but it seems it still doesn't compile in your playground. Sorry about my link. This is what I meant [Playground](tinyurl.com/mrjf8wc8)
Like I said, I think you need the cast. This isn't shown in my playground. The other error is deliberate, to show that the compiler can now check the method map for completeness and correctness.
I think the user [jcalz] got the solution. You can check it in the comment of the question.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.