Imagine I have 2 different functions that I want to call depending on the passed function name. Also, I want to add type annotations for the function parameters.
Minimal example:
const a = (options: { o1: number }) => {}
const b = (options: { o2: string }) => {}
const functions = { a, b }
type A = {
name: 'a'
options: Parameters<typeof a>[0]
}
type B = {
name: 'b'
options: Parameters<typeof b>[0]
}
type Data = A | B
(data: Data) => {
functions[data.name](data.options)
}
But TypeScript won't allow passing data.options
:
TS2345: Argument of type '{ o1: number; } | { o2: string; }' is not assignable to parameter of type '{ o1: number; } & { o2: string; }'. Type '{ o1: number; }' is not assignable to type '{ o1: number; } & { o2: string; }'. Property 'o2' is missing in type '{ o1: number; }' but required in type '{ o2: string; }'.
Looks like TypeScript transforms
type Data = A | B
into
type Data = {
name: 'a' | 'b'
options: Parameters<typeof a>[0] | Parameters<typeof b>[0]
}
which seems incorrect for me
CodePudding user response:
The issue here is one I call "correlated union types". This was reported in microsoft/TypeScript#30581 and has been addressed via microsoft/TypeScript#47109, which advises a certain kind of refactoring to help the compiler understand what you're doing.
In general, if you have a function f
and an argument x
, both of which are of union types, like this:
declare const f: ((options: { o1: number }) => void) | ((options: { o2: string }) => void)
declare const x: { o1: number } | { o2: string }
then it is not safe to call the function using that argument, and the TypeScript compiler generates an error telling you this:
f(x); // error!
// Argument of type '{ o1: number; } | { o2: string; }' is not assignable to
// parameter of type '{ o1: number; } & { o2: string; }'.
That error message might be confusing, but it's just saying that the argument union doesn't work; the only thing it knows would work would be an argument intersection, as per the support for calling union types introduced in TS3.3 and implemented in microsoft/TypeScript#29011.
It makes sense that this would be an error, since for all the compiler knows, f
might be of type (options: { o1: number }) => void)
while x
is of type { o2: string }
. That is, they might be mismatched.
Now, in your code you're doing this:
const f = functions[data.name];
const x = data.options;
f(x); // same error!
The compiler produces the same error as above and for the same reason: f
and x
are both of union types, and for all the compiler knows, they might be mismatched. But the compiler doesn't realize that the types of f
and x
are correlated to each other. They both depend on the type of data
in such a way as to make a mismatch impossible.
This is effectively a limitation of TypeScript. If you leave f
and x
of union types, the compiler will not be able to see any correlation between them.
The refactoring recommended in microsoft/TypeScript#47109 is to make your function generic instead of a union, and pass it an argument of a corresponding generic type. In order for this to work, the function and argument types must be curated quite carefully.
First, we can create a DataMap
mapping interface which represents a basic key-value relationship from which we can build your other types:
interface DataMap {
a: { o1: number },
b: { o2: string }
};
Then, we can annotate your functions
variable as a mapped type which explicitly iterates over the key and value types of DataMap
:
const a = (options: { o1: number }) => { }
const b = (options: { o2: string }) => { }
const functions: { [K in keyof DataMap]: (options: DataMap[K]) => void } =
{ a, b }
Instead of Data
being a straight union of hardcoded A
and B
, we can rewrite Data
as a generic distributive object type (which, as mentioned in ms/TS#47109, is a mapped type into which we immediately index:
type Data<K extends keyof DataMap = keyof DataMap> =
{ [P in K]: { name: P, options: DataMap[P] } }[K]
From this we can define A
or B
if you want:
type A = Data<"a">
type B = Data<"b">
And if you want, you can use the Data
type without a type argument (thanks to its generic default) and see that it's the same type as your version:
type D = Data
/* type D = {
name: "a"; options: { o1: number; };
} | {
name: "b"; options: { o2: string; };
} */
And now we can write your function; instead of accepting Data
, we accept Data<K>
for generic K
:
<K extends keyof DataMap>(data: Data<K>) => {
functions[data.name](data.options); // okay
}
Yay, no compiler error! That works because the compiler sees the types of functions[data.name]
and data.options
as appropriately related generics:
<K extends keyof DataMap>(data: Data<K>) => {
const f: (options: DataMap[K]) => void = functions[data.name];
const x: DataMap[K] = data.options;
f(x); // okay
}
You can see that the argument x
is of type DataMap[K]
, and the function f
is of type (options: DataMap[K]) => void
. So the argument is seen to be exactly the type that the function accepts, and there is no longer a compiler error.
CodePudding user response:
TS2345: Argument of type '{ o1: number; } | { o2: string; }' is not assignable to parameter of type '{ o1: number; } & { o2: string; }'.
This error message is explaining what is happening.
The A | B
operator tells TypeScript that the value could be either A or B. You want to create a type which has value A and B. In the former case, TypeScript cannot guarantee that A is present because the object might only have value B, and vice versa. In the latter case TypeScript knows that both A and B will be present. The operator for this is A & B
.
The following should work:
const a = (options: { o1: number }) => {}
const b = (options: { o2: string }) => {}
const functions = { a, b }
type A = {
name: 'a'
options: Parameters<typeof a>[0]
}
type B = {
name: 'b'
options: Parameters<typeof b>[0]
}
// only change is here
type Data = A & B
(data: Data) => {
functions[data.name](data.options)
}