Reputation: 423
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?
Upvotes: 6
Views: 5465
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.
Upvotes: 7
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)
}
Upvotes: 0
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)
}
}
Upvotes: 1