#How to use a discriminated union without using `any`?
64 messages · Page 1 of 1 (latest)
Preview:```ts
// ------------
// A basic enum
// ------------
enum Fruit {
Apple,
Banana,
}
// -------------------------------------------
// A basic data interface for each enum member
// -------------------------------------------
interface AppleData {
type: Fruit.Apple;
...```
At the bottom (line 61), it seems we have to use any. Is there a way to code this better so that TypeScript is smart enough to understand?
!:corr*
There's a particular pattern that is safe but hard for the Typescript compiler to handle, which I call the "correspondence problem":
const functionsWithArguments = [
{ func: (arg: string) => {}, arg: "foo" },
{ func: (arg: number) => {}, arg: 0 },
];
for (const { func, arg } of functionsWithArguments) {
func(arg);
// ^^^
// Argument of type 'string | number' is not assignable to parameter of type 'never'.
// Type 'string' is not assignable to type 'never'.
}
The problem is that func is typed as (x: string) => void | (x: number) => void and arg is string | number, but the compiler can't prove that they "correspond": that, for example, arg is only a string when func accepts strings.
As far as the type are concerned, arg could be number, and func could be (arg: string) => void, and that would be a type-error. It's easy for us to see that that won't happen, but that requires understanding the program at a higher-level than the level the compiler operates.
Depending on the specifics there's sometimes clever fixes, but usually I recommend using a type assertion and ignoring the issue:
func(arg as never);
Is it this?
With Retsams example it is not possible
I'm cooking now so can't inspect 😄
Yeah in your use case I would just do a switch statement
unless it's really long
async function processQueue(data: Data) {
switch (data.type) {
case Fruit.Apple: {
FRUIT_FUNCTIONS[Fruit.Apple](data)
break
}
case Fruit.Banana: {
FRUIT_FUNCTIONS[Fruit.Banana](data)
break
}
}
}
yuck
otherwise you can do as never to avoid using as any, but it's not really different
that seems much worse than just using as never
Well, no. It's typesafe
So it's better for safely. Worse for verbosity.
As I've used TS more I've found that there are quite a few things that it can't handle due to type checker limitations. And usually the simplest approach is to write something out that is a bit longer. I'd prefer just to write a little more code than use unsafe hacks mostly.
In a strongly typed language you'd have to do the switch case way. It's kinda "normal" in that environment. And the TS designer is also the C# guy, so a lot of that carries over I think.
Trying to iterate and apply like this is a very dynamically typed language thing, and the checker isn't smart enough to handle that yet.
in the PR i linked, it says that a switch statement isn't necessary, i just need help with modifying the playground to satisfy the PR
I don't think that is the same issue
it seems like the same issue, since it uses a discriminated union
I mean, this is well known and no one here seems to think there is a solution
I'll read a bit more and try to see what that is
Yeah in that example record.f and record.v are part of the same object
So it's correleting between 2 parts of one type
What you want is to correlate because two different types, the data union and the functions list, and TS doesn't usually recognise relationships between different items
If you were passing both the function and the data to processQueue we could make them correlate, but we aren't
oh, i see
Is there a code comment I can put that links to retsams' correspondance problem?
I don't think so
But it's pretty well known to TS devs, so I think with a simple comment it's OK
!:U2I
hmm
!:U2I*
async function processQueue(data: Data) {
const func = FRUIT_FUNCTIONS[data.type];
// intersection of param0 of each function
type Params = UnionToIntersection<Parameters<typeof FRUIT_FUNCTIONS[keyof typeof FRUIT_FUNCTIONS]>[0]>
func(data as Params);
}
You can do this too. This is mimicing what TS is doing, so it's kind of explanatory
But, it often collapses to never so it's kind of useless code.
Sometimes Params won't be never, and in those cases you still get some type checking on the cast, i.e. the data has to be related somehow to Params. But if Params has resolved to never then you never get warnings sadly.
async function processQueue(data: Data) {
const func = FRUIT_FUNCTIONS[data.type];
// intersection of param0 of each function
type ParamsUnion = Parameters<typeof FRUIT_FUNCTIONS[keyof typeof FRUIT_FUNCTIONS]>[0]
type ParamsIntersection = UnionToIntersection<ParamsUnion>
func(data satisfies ParamsUnion as ParamsIntersection);
}
This one would give you a type check with the satisfies, and then a cast with the as, so pretty much the safest it can be.
The solution linked in SO is not type safe, consumer is allowed to do:
callerMethod<'a' | 'b'>('a', myDataForB)
The solution in the PR does seem to work and is type safe though, first time I read about it. The SO's answer can be rewritten into the PR's form.
Can you rewrite the playground in the PR's form?
The SO answer, or your original question?
The playground here:
I don't think it can work for your case, because your functions and data are separate.
I wouldn't say it's about being idiomatic, but rather simply it's not the use case for it.
Your use case only wants data from the caller, while you define functions yourself; there are use cases where you want both data and functions from the caller and you are simply just calling them.
I've settled on this pattern three times now when writing a client/server application.
Where I have an enum of all of the possible commands that e.g. the client can send to the server.
and then I have a command handler for each member in the Command enum.
And each command has different kinds of data associated with it.
Yeah, usually I just as never and go on with my day.
The PR's solution is a neat trick for the "caller passing in both functions and data" use case, but I suspect that use case isn't very common.
I would say vast majority of the times where you run into correspondence problem, is because of the use case like yours, where you are just essentially using the function map as a nicer way to write switch.
There's performance benefits to not using a switch, right?
I dont know if the JIT would compile a switch statement to a jump table.
Eh, you can benchmark it, but I wouldn't bother optimizing that unless you have positively identified it would actually speed up your application.