In some cases the typescript compiler doesn't detect the types correct which requires me to be redundant. In my head this types are absolutly safe declared. Do I miss something or is the compiler incorrect? And is there any way I can help him autodetect the types correct without repeating myself?
interface Dto {
value1: boolean;
value2: string;
value3: string;
}
class Model {
firstValue!: boolean;
secondValue!: number;
thirdValue!: string;
parse<
dtoName extends keyof Dto,
>(entry: dtoName, raw: Dto[dtoName]): void {
switch(entry) {
case 'value1':
/* raw is save a boolean isn't it? Its declared as …
Dto[dtoName]
= Dto['value1']
= boolean
*/
this.firstValue = raw;
break;
case 'value2':
this.secondValue = Number(raw);
break;
case 'value3':
this.thirdValue = raw; // same here
break;
default:
throw new Error(`Unknown entry ${entry}`);
}
}
}
Link to Playground
TypeScript is currently unable to use control flow analysis to affect generic type parameters like K
(changed from dtoName
which is an unconventionally named type parameter; at the very least it should be DtoName
, but K
is even more conventional for a keylike type parameter). So when you check entry
, TypeScript can narrow entry
from K
to, say, "value1"
, but K
itself remains unchanged. And that means raw
of type Dto[K]
is also unchanged. There are various open feature requests, like microsoft/TypeScript#33014, asking for something better here, but for now it's not possible.
One major stumbling block toward implementing that is that, as written, K
can itself be a union type. It might not be "value1"
, "value2"
, or "value3"
. It could also be, say, "value1" | "value2"
. And that means Dto[K]
could be boolean | string
. So nothing prevents a call like the following:
new Model().parse(
Math.random() < 0.99 ? "value1" : "value2",
"oops"
);
That is accepted by TypeScript, yet there is a 99% chance that you receive an input your implementation doesn't expect. So the compiler error is technically correct; it really is true that entry
might be "value1"
while raw
might be of type string
instead of boolean
. So part of getting better language support for the kind of generic code you're writing would involve some way to say "K
can't be a union", such as the feature request at microsoft/TypeScript#27808. And again, for now, it's not part of the language.
So you'll need to work around it. Either you give up on control flow analysis (such as switch
/case
) or you give up on generics or you use something like type assertions and give up on compiler-verified type safety.
In your case, you can actually give up on generics without losing anything. The return type of parse()
is void
and does not depend on the inputs. That means the function doesn't need to be generic. Instead of having your parameters be generic, you can the function take a tuple-typed rest parameter, like this:
parse(...[entry, raw]:
[entry: "value1", raw: boolean] |
[entry: "value2", raw: string] |
[entry: "value3", raw: string]
): void {
switch (entry) {
case 'value1':
this.firstValue = raw; // okay
break;
case 'value2':
this.secondValue = Number(raw);
break;
case 'value3':
this.thirdValue = raw; // okay
break;
default:
throw new Error(`Unknown entry ${entry}`);
}
}
That union-of-tuples is a discriminated union where the first element can be used as a discriminant to narrow the type of the second element. The entry
and raw
variables are destructured from the rest parameter, and TypeScript supports control flow analysis for destructured discriminated unions. It might look weird for the function to take (...[entry, raw])
instead of (entry, raw)
, but it's equivalent, and this approach has the advantage of actually working for you. So now when you check entry
, TypeScript can narrow raw
accordingly.
And now the problematic call from before is disallowed:
new Model().parse(
Math.random() < 0.99 ? "value1" : "value2",
"oops"
); // error!
Because ["value1" | "value2", string]
matches none of the three union members.
That's the answer to the question as asked, although it's tedious and redundant to have to write out that union of tuples yourself. Luckily you can compute it from Dto
as follows:
type Args = { [K in keyof Dto]: [entry: K, raw: Dto[K]] }[keyof Dto]
That's a distributive object type as coined in microsoft/TypeScript#47109, which is a mapped type into which you immediately index to get the union of the mapped type's properties. If you have a type function F<K>
you'd like to distribute over unions in K
, you can write {[P in K]: F<P>}[K]
. So the above becomes the union of [entry: K, raw: Dto[K]]
for each K
in keyof Dto
.
Armed with that, you can make parse()
's rest parameter be of type Args
:
parse(...[entry, raw]: Args): void { }
and everything still works.