Hacker News new | ask | show | jobs
by duped 997 days ago
Consider this code:

    fn foo () -> A | B | C {
        if condition {
            bar();
        } else {
            baz();
        }
    }

    fn bar() -> A | B {
        ...
    }

    fn baz() -> B | C {
        ...
    }

vs

    fn foo () -> Either<A, Either<B, C> {
        if condition {
            match bar() {
                Either::Left(a) => Either::Left(a),
                Either::Right(b) => Either::Right(Either::Left(b)),
            }
        } else {
            match baz() {
                Either::Left(b) => Either::Right(Either::Left(b),
                Either::Right(c) => Either::Right(Either::Right(b)),
            }
        }
    }

    fn bar() -> Either<A, B> {
        ...
    }

    fn baz() -> Either<B, C> {
        ...
    }
The latter code composes poorly and requires an extra branch at runtime. It is fundamentally more complex to dispatch on nested discriminated unions instead of flat non-discriminated unions both for the programmer to write, read, and for the runtime to execute.

The compiler can also optimize the representation of the anonymous enum based on the context in which its created, whereas its more difficult to do that in the discriminated case.

This isn't a controversial opinion, there are mountains of Typescript written in this style.

1 comments

So basically the idea here is that you want to have TS-style untagged unions, but instead they're also tagged, but still unify and compose the way they do in TS? Then why couldn't you just do `{ tag: A, data: … } | { tag: B, … } | { tag: C, … }`? Wouldn't it solve your problem?

We didn't start with composability as a requirement but you're right in that if it's a goal then nesting Either's is a rather poor solution. A better fit would be variants based on row polymorphism as I described in the reply to the other poster.

It wouldn't be a 1:1 mapping to your first example though, if your union is ultimately closed (as in your first example) then you'd still need to have one extra no-op function call to unify the types. Not a big deal but row-polymorphic variants lose here. On the other hand, IMO the possibility of having them open as well is the killer feature.

Ultimately though, I don't like this style of type unification as the one happening in your first example. Shaped by the languages I'm working with, I simply don't end up in situations where I'd need something like this. I just approach the problems differently. But this is more of a subjective territory here.