Lukasz Prus
Lukasz Prus

Reputation: 423

Argument of type 'string | number' is not assignable to parameter of type 'never'

Snippet and error:

const methods = {
  a(value: number) {},
  b(value: string) {}
};

function callMethodWithArg(methodAndArg: { method: 'a'; arg: number; } | { method: 'b'; arg: string; }) {
  methods[methodAndArg.method](methodAndArg.arg);
}

Argument of type 'string | number' is not assignable to parameter of type 'never'. Type 'string' is not assignable to type 'never'.

Looks like typescript isn't intelligent enough to figure out that method a can only be called with a number and method b can only be called with a string.

Any suggestions how to type this properly?

Playground

Upvotes: 6

Views: 5465

Answers (3)

jcalz
jcalz

Reputation: 330266

The problem here is that for TypeScript up to version 4.5 there is no support for what I've been calling "correlated unions", as discussed in microsoft/TypeScript#30581. As you said, the compiler can't understand that method a can only be called with a number and method b can only be called with a string. If we break the single line into several to inspect the types of the function and its argument, we see that each is a union type:

type MethodAndArg = { method: 'a'; arg: number; } | { method: 'b'; arg: string; }

function callMethodWithArg(methodAndArg: MethodAndArg) {
    const f = methods[methodAndArg.method];
    // const f: ((value: number) => void) | ((value: string) => void)
    const arg = (methodAndArg.arg);
    // const arg: string | number
    f(arg) // error!
}

That f is a union of functions, and arg is a union of argument types. If f and arg weren't known to be correlated (say you got f from methodAndArg and arg from a different object of the same type), then of course you couldn't call the union of functions with a union of argument types. Maybe f is methods.a and b is a number. The compiler tracks the types of f and arg separately, as if they were independent. It does not track their source.


In TypeScript 4.5 and below, the only ways I know to deal with this are to either write type safe but redundant code, like this:

function callMethodWithArg(methodAndArg: MethodAndArg) {
    if (methodAndArg.method === "a") {
        methods[methodAndArg.method](methodAndArg.arg);
    } else {
        methods[methodAndArg.method](methodAndArg.arg);
    }
}

where you use control flow analysis to narrow methodAndArg to each possible subtype, and then the compiler can verify the function call in each case.

Or, you can write terse yet unsafe code, like this:

function callMethodWithArg(methodAndArg: MethodAndArg) {
    (methods[methodAndArg.method] as (value: number | string) => void)(
        methodAndArg.arg);
}

where you use a type assertion to pretend that the function can accept all number and string inputs, even though in actuality it will only accept one of those. That is not safe because you could pass in Math.random()<0.5 ? "a" : 1 instead of methodAndarg.arg and get no errors.


In TypeScript 4.6 and above, you should be able to use something called a "distributive object type" as introduced in microsoft/TypeScript#47109. The idea is to come up with a mapping object type which represents a mapping from some key name to a function parameter, and then use this mapping object type to generate your other types. The compiler will be able to "see" the correlation as long as things are properly expressed in terms of the mapping type.

Here's how it will look for your example:

// mapping type
interface ArgMap {
    a: number;
    b: string;
}

So ArgMap is the mapping type. In a sense it captures the type relationship most simply (e.g., "a goes with number and b goes with string"). Now we can build your other types:

// map over ArgMap to get methods type
type Methods = { [K in keyof ArgMap]: (value: ArgMap[K]) => void }

const methods: Methods = {
    a(value: number) { },
    b(value: string) { }
};

The Methods type is a mapped type over ArgMap, and we annotate methods as being of this type. This annotation is important, otherwise the link between methods and the type of methodAndArg will be broken.

Next one:

// map over ArgMap and index into it to get generic MethodAndArg type
type MethodAndArg<K extends keyof ArgMap = keyof ArgMap> =
    { [P in K]: { method: P, arg: ArgMap[P] } }[K]

Here we have a generic MethodAndArg<K> type, where K defaults to keyof ArgMap. If you just write MethodAndArg it is the same union type as before, but if you write MethodAndArg<"a"> it will just be the a method/arg pair, and MethodArg<"b"> is just the b method/arg pair. This is the "distributive object type" we need.

Finally, your callMethodWithArg() function:

// make callMethodWithArg generic in the key type of ArgMap
function callMethodWithArg<K extends keyof Methods>(methodAndArg: MethodAndArg<K>) {
    methods[methodAndArg.method](methodAndArg.arg); // okay
}

This function is generic in K, and methodAndArg is of type MethodAndarg<K>. The correlated function call compiles with no problem, without any redundant JS code or any TS type assertions. This is both type safe and produces terse JS code. And when you call callMethodWithArg() the compiler will (in TS4.6+, remember) infer K properly:

callMethodWithArg({ method: "a", arg: 123 }); // okay
// K inferred as "a"

callMethodWithArg({ method: "b", arg: "xyz" }); // okay
// K inferred as "b"

callMethodWithArg({ method: "a", arg: "xyz" }); // error! 
// K inferred as "a", but arg is wrong

So this is about as good as it gets!


I see that you really would like something with even less work, where the compiler would be able to "see" the correlation without requiring annotating methods. Maybe in some future version of TypeScript this could happen, but for now I don't think it's possible.

Playground link to code

Upvotes: 7

Salmin Skenderovic
Salmin Skenderovic

Reputation: 1720

My solution would be to use Type Predicates to cast the method as either Shape A or B (or however many shapes you need)

type MethodA = (value: number) => void
type MethodB = (value: string) => void

type ArgA = { method: 'a'; arg: number; }
type ArgB = { method: 'b'; arg: string; }

type Methods = {
  [key: string]: MethodA | MethodB
}

const methods: Methods = {
  a(value: number) { },
  b(value: string) { }
};


function callMethodWithArg(methodAndArg: ArgA | ArgB) {
  const method = methods[methodAndArg.method]

  if (determinShape(methodAndArg)) {
    (method as MethodA)(methodAndArg.arg)
  } else {
    (method as MethodB)(methodAndArg.arg)
  }
}


/** Will cast methodAndArg as ArgA if true, else ArgB */
function determinShape(methodAndArg: ArgA | ArgB): methodAndArg is ArgA {
  return Number.isFinite(methodAndArg.arg)
}

TS Playground

Upvotes: 0

A_A
A_A

Reputation: 1932

I'm not sure if there is a cleaner solution, but this will work if you don't have too many cases:

const methods = {
  a(value: number) {},
  b(value: string) {}
};

function callMethodWithArg(methodAndArg: { method: 'a'; arg: number; } | { method: 'b'; arg: string; }) {
  if (methodAndArg.method === 'a') {
    // now it knows that method has to be 'a' and arg is a number
    methods[methodAndArg.method](methodAndArg.arg)
  } else if (methodAndArg.method === 'b') {
    methods[methodAndArg.method](methodAndArg.arg)
  }
}

Playground link

Upvotes: 1

Related Questions