#How to use a discriminated union without using `any`?

64 messages · Page 1 of 1 (latest)

lucid latch
#

See below playground.

delicate gobletBOT
#
zamiel#0

Preview:```ts
// ------------
// A basic enum
// ------------

enum Fruit {
Apple,
Banana,
}

// -------------------------------------------
// A basic data interface for each enum member
// -------------------------------------------

interface AppleData {
type: Fruit.Apple;
...```

lucid latch
#

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?

spare vault
#

!:corr*

delicate gobletBOT
#
retsam19#0
`!retsam19:correspondence-problem`:

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);
spare vault
#

Is it this?

spare vault
#

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
    }
  }
}
lucid latch
#

yuck

spare vault
#

otherwise you can do as never to avoid using as any, but it's not really different

lucid latch
#

that seems much worse than just using as never

spare vault
#

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.

lucid latch
#

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

spare vault
#

I don't think that is the same issue

lucid latch
#

it seems like the same issue, since it uses a discriminated union

spare vault
#

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

lucid latch
#

oh, i see

#

Is there a code comment I can put that links to retsams' correspondance problem?

spare vault
#

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.

mellow ember
#

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.

lucid latch
mellow ember
#

The SO answer, or your original question?

lucid latch
#

The playground here:

delicate gobletBOT
mellow ember
#

I don't think it can work for your case, because your functions and data are separate.

lucid latch
#

Ah, ok.

#

I doubt it is idiomatic to combine functions and data?

mellow ember
#

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.

lucid latch
#

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.

mellow ember
#

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.

lucid latch
#

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.

mellow ember
#

Eh, you can benchmark it, but I wouldn't bother optimizing that unless you have positively identified it would actually speed up your application.