Issue
I meet a typescript multi-parameter type constraint issue.
Firstly there is a running ok example:
interface ABC {
abc: 1;
}
interface XYZ {
xyz: 2;
}
interface HahaParams {
type: 'haha';
value: ABC;
gen: number;
}
interface YoyoParams {
type: 'yoyo';
value: XYZ;
gen: string;
}
type Params = HahaParams | YoyoParams;
function test<
T extends Params['type'],
V extends Extract<Params, {type: T}>['value'],
G extends Extract<Params, {type: T}>['gen'],
>(
type: T,
value: V,
gen: G,
){
console.log(type, value, gen);
}
test('haha', {abc: 1}, 1); // run ok
test('haha', {xyz: 2}, 1); // type error, i get an error as expected as below
// TS2345: Argument of type '{ xyz: number; }' is not assignable to parameter of type 'ABC'.
// Object literal may only specify known properties, and 'xyz' does not exist in type 'ABC'.
The first example is running ok, the type error is expected.
But when i change the type of parameter 'gen' to function, then the error will arise, see the example:
interface ABC {
abc: 1;
}
interface XYZ {
xyz: 2;
}
interface HahaParams {
type: 'haha';
value: ABC;
gen: (abc: ABC) => void;
}
interface YoyoParams {
type: 'yoyo';
value: XYZ;
gen: (xyz: XYZ) => void;
}
type Params = HahaParams | YoyoParams;
function test<
T extends Params['type'],
V extends Extract<Params, {type: T}>['value'],
G extends Extract<Params, {type: T}>['gen'],
>(
type: T,
value: V,
gen: G,
){
console.log(type, value, gen(value)); // type error, is get an unexpected:
// TS2345: Argument of type 'ABC | XYZ' is not assignable to parameter of type 'ABC & XYZ'.
// Type 'ABC' is not assignable to type 'ABC & XYZ'.
// Property 'xyz' is missing in type 'ABC' but required in type 'XYZ'.
}
test('haha', {abc: 1}, (n) => n);
So as you see, according to the first example, i think the parameter 'gen' should be narrowed to type "HahaParams['gen']" or "YoyoParams['gen']":
(abc: ABC) => ABC
or
(xyz: XYZ) => XYZ
But the typescript compiler shows it should be
(arg0: ABC & XYZ) => ABC | XYZ
I don't know what happens in the second example, does that i change the 'gen' parameter's type to function affects the type judgment of the compiler.
Is my thought wrong? How can i fix it?
Solution
In this case, the compiler is correct to complain that gen(value)
is unsafe. While the intended use case for test()
is that T
will either be of type "haha"
or of type "yoyo"
, nothing prevents someone from calling test()
with T
being the full union "haha" | "yoyo"
. Here's one way to get that to happen:
test(
Math.random() < 0.5 ? 'haha' : 'yoyo',
{ abc: 1 }, (n: XYZ) => console.log(n.xyz.toFixed()),
);
// T is "haha" | "yoyo"
The first argument to test
is of type "haha" | "yoyo"
. Because of this, the compiler infers T
to be "haha" | "yoyo"
, which allows the second argument to be of type ABC | XYZ
and the third argument to be of type ((x: ABC)=>void) | ((x: XYZ)=>void)
. And so the compiler is perfectly happy to allow value
to be of type ABC
while gen
is of type (x: XYZ)=>void
.
But the above call to test()
leads to a runtime error, because calling gen(value)
inside of test
is unsafe. Since gen
can be of type ((x: ABC)=>void) | ((x: XYZ)=>void)
, the only safe way to call it would be to give it an argument which will definitely not run into an error no matter which function type it actually is. That has to be both an ABC
and an XYZ
, also known as the intersection type ABC & XYZ
. (See the TS3.3 release notes for the feature that interprets unions of functions as accepting intersections of arguments). So that explains the error message...
...but this isn't the use case you're trying to support; you don't want T
to be the full union type. Currently there is no way to tell the compiler that a type parameter can be only one member of a union; there's a feature request at microsoft/TypeScript#27808 but it's not part of the language.
In order to support your intended use cases, where you are either calling test()
with all ABC
stuff or with all XYZ
stuff, you will need to refactor. And in order to have the compiler verify type safety, you will need to refactor in a certain way that has only been supported since TS4.6.
Here's the refactoring:
type ParamMap = { haha: ABC, yoyo: XYZ }
The ParamMap
type is a helper type that keeps track of the relationship between the type
names "haha"
/"yoyo"
and the value
types ABC
/XYZ
.
type TestParams<T extends keyof ParamMap> = { [K in T]:
[type: K, value: ParamMap[K], gen: (arg: ParamMap[K]) => void]
}[T];
Then TestParams<T>
is a distributive object type as coined in the in microsoft/TypeScript#47109, the PR that enabled this refactoring. A distributive object type is one in which you make a mapped type over some set of keys and then immediately index into it with those same keys to get a union. You can verify that TestParams<"haha" | "yoyo">
evaluates to the following union type:
type TestParamsUnion = TestParams<"haha" | "yoyo">
// type TestParamsUnion =
// [type: "haha", value: ABC, gen: (arg: ABC) => void] |
// [type: "yoyo", value: XYZ, gen: (arg: XYZ) => void]
These tuple types correspond to the parameter lists we want test()
to support. And indeed, test()
now takes a rest argument of a TestParams<T>
type:
function test<T extends keyof ParamMap>(...[type, value, gen]: TestParams<T>) {
// (parameter) value: ParamMap[T]
// (parameter) gen: (arg: ParamMap[T]) => void
console.log(type, value, gen(value)); // okay
}
The types of gen
and value
are now seen to be correlated in a way that the compiler can verify as safe. gen
takes an argument of type ParamMap[T]
and value
is a value of exactly that type. So the call succeeds inside the implementation of test()
.
And from the caller's side of test()
, things are also good. Supported calls are still accepted:
test('haha', { abc: 1 }, (n) => n); // okay
Whereas the call we accidentally allowed before is now prohibited, as desired:
test(
Math.random() < 0.5 ? 'haha' : 'yoyo',
{ abc: 1 }, (n: XYZ) => console.log(n.xyz.toFixed()),
); // error!
// Argument of type '["haha" | "yoyo", { abc: 1; }, (n: XYZ) => void]' is not
// assignable to parameter of type 'TestParams<keyof ParamMap>'.
Answered By - jcalz
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.