1. 15
  1.  

  2. 1

    Maybe it is obvious (it’s been a while since I used Go), but how would it look with the upcoming generics?

    1. 2

      This is pretty out of touch, TBH. If you follow the Go dev issues, there’s a really obvious way to do sum types that falls out of how generics will work in Go 1.18. They haven’t talked about it publicly yet because they don’t want to promise it in case it turns out to be infeasible or create weird edge cases for whatever reason, but you can just look at how interface constraints work and remove an otherwise useless clause from the generics spec and tada you’ve got sum types.

      Long story short, in generic Go, if you want to write an add function for numeric types, you do something like

      type Number interface {
        int, int8, uint8 //etc...
      }
      
      func add[T Number](a, b T) T {
         return a + b
      }
      

      Well, you’ve got Number and it’s defined as being an int or an int8, etc. Why can’t you use it a Number as a regular variable? Well because the spec says you can’t because they haven’t worked out all the implications yet. But if you could it would be sum type. So the obvious thing to do is just to lift that restriction and let you use constraint types as regular interfaces.

      1. 3

        Lifting the restriction of using constraints as regular values would be great, and it’s something I wish to see pursued, but they wouldn’t be sum types, they would be union types.

        1. 1

          Please give us union types. I can’t count how often sum types lead to more complicated code and less composability. Try representing errors or events as sum types and you’ll quickly run into components having to wrap other components‘ types and getting ugly nesting (edit: or lots of copy-paste). With union types it’s trivial to add more cases or handle one case and pass the rest on. I never understood why people want sum types.

          1. 3

            I agree that union types are great and are usually what people need, and it’s a shame that so few languages have them.

            As for why are sum types more popular, sum types give rise to a richer algebra, which apart from its intrinsic quality means you can have type-inference, and they can also be somewhat combined with subtyping while retaining type erasure. This is much harder (impossible?) with union types.

            1. 1

              I wasn’t familiar with this distinction. Is this a good intro? https://blog.waleedkhan.name/union-vs-sum-types/

              1. 3

                It’s a pretty good tutorial that shows the practical differences in usage, although the mathematical treatment is somewhat fast and loose.

                If you are interested in the rigorous treatment of union types in the context of type theory (as opposed to naive set theory, like the above article), the canonical texts are a collection of papers by Benjamin C. Pierce.

              2. 1

                Definitely, union types are much harder for the type system - though the recently released Scala 3 has them, too (apart from TypeScript), so I wouldn’t say it’s impossible. Maybe with structural typing you have other difficulties. I still believe it’s possible, even if one has to redefine the problem.

                But what I meant was why “end-developers” (as opposed to compiler writers) often want sum types even if they’ve heard about union types. It’s probably more about showing with good examples how one would use union types and why they’re strictly better than sum types, i.e. without any disadvantages.

              3. 1

                I think the main downside of union types is ambiguity. For the example of:

                type Either[A, B any] union {
                  a A
                  b B
                }
                
                func analyse[A, B any](it: Either[A, B]) {
                switch it.(type):
                  case A: fmt.Println("A")
                  case B: fmt.Println("B")
                }
                
                func widget() {
                 val thing Either[int, int] = Either { b: 42 }
                 analyse(thing)
                }
                

                What should it print? If Either[int, int] is represented as a plain union type, which relies on Go’s run type type information (not to mention boxing) to tell which case it represents, because both cases are int, there’s no way to tell which case it was originally.

                Not to mention fun edge cases around interface types that might well overlap. Just imagine we have a plain interface for Number, and we want to have a function that returns a Result[Number, error]. Let’s also imagine we’re working with floating point numbers, and someone who loves chaos decides that because floats include a value for “Not a Number”, it should implement error.

                There’s also plenty of practical experience writing industry software with sum types, in everything from O’Caml, Rust, and even Typescript.

                … and you’ll quickly run into components having to wrap other components‘ types and getting ugly nesting

                I’ve not seen that myself–do you have an example?

                1. 3

                  Your example is not the best way to use union types. Union types can model sum types: An Either consists of Left and Right (or A and B in your case), so define those two, define typedef Either = Left | Right and put your ints wrapped into those two - exactly like you would with sum types.

                  The point of union types is not to leave off all tags/constructors (Left and Right). The point is to not have nominal typing over the union (the name Either), but structural typing (what it stands for: Left | Right) and be allowed to arbitrarily add more cases like Either | int (equivalent to Left | Right | int).

                  So, the Either just becomes an alias for Left | Right. In contrast, with sum types the Either becomes a island that can’t be combined into Either | int. Instead you have to double-wrap Either[Either[X, Y], int] or convert that to Triple[X, Y, int] and if you later want to remove the Y you again convert that to Either[X, int]. Horrible.

                  I see this happen in practice e.g. under Android where people often model the ViewModel’s states and events as sum types (sealed classes) and then when trying to subclass and add an extra event type or extra state you’re in a copy-paste or wrapping mess. This is why I’ve turned to interfaces for modeling events because then you can at least use multiple inheritance to have set-like composition. It’s not quite as good as union types, but so much better than sum types and provides the same safety guarantees (exhaustive checks) because you’re forced to implement the whole interface (each event being one method) and can’t forget anything.

                  1. 1

                    Ah, thanks, I see what you’re getting at – yeah, having being able to represent an open set of cases is definitely useful in a lot of cases. And if you had families of related types (eg: all events except mouse related ones, or simplifying special forms in a compiler) that would be a real pain. Have you seen O’Caml’s Polymorphic Variants? They’re used quite heavily in the GUI libraries (Tk, last time I checked) for one, and I’d definitely love to see them more widely adopted.

                    1. 2

                      Polymorphic variants still require tags. That’s bad.

                      Tags are optional in union types, which allows for more ergonomic APIs e.g. when your function want to accept string | List<string>. This way you can pass around values without unpacking/repacking in different tags to translate between different worlds just because you were forced to use tags even where it doesn’t matter.

                      With tags you have a function taking Either[string, int] and a different function taking Either[int, string] and they’re just incompatible without translation. With union types you have just int | string and string | int and there’s no translation needed. Not even when forwarding the value to a function taking string | int | bool. It’s still a compatible type.

                      With enforced tags you’re forced to over-categorize and over-specify the problem which breaks composability. Left and Right are just meaningless tags with nonsense names. How do you decide if your int goes into Left or Right? That’s just random. It’s much more meaningful to work with Error | int or Buy | Sell or string | List<string> or whatever meaningful names and sets of tagged and tagless types are best for describing the problem space. The whole Either = Left | Right type is just an ugly hack caused by a limitation of sum types (enforced tags).

              4. 1

                I hadn’t seen people distinguish “sum” vs. “union” before instead of just treating them as synonyms, but it’s a useful distinction. I think there’s a better than even chance that Go will adopt union types. I think there is virtually no chance that Go will adopt sum types given the current leadership. (I dunno, maybe if Google fires the Plan 9 guys, but not as long as they lead the project.)

                1. 2

                  You’re not alone! It’s a common thing we have to clarify on the PL design discord I frequent. Sum types are better though of as ‘tagged unions’ and are pretty useful in their own right. But ‘unions’ are handy too.

          2. 1

            The last point about “adding methods to closed interfaces” sparked an interesting train of thought for me. I blanked on what that meant for a moment, until I read the example and realized that it’s talking about using interfaces as roles/traits, which both require some methods of the types that implement them and provide some methods to the types that consume them. Sometimes that’s a really useful way of making things composable.

            Previously I had told people that this would be a terrible idea in Go because Go interfaces are “open membership”; anything that matches an interface’s method set is assignable to a variable of the interface type, and having the interface provide methods to things that didn’t even ask for them would be terrible action at a distance, as well as prone to conflicts… but on further thought, that isn’t true, and you don’t even need closed membership to repair it. If interfaces provided methods, those methods would only be callable on values that are statically that interface type, they wouldn’t just be stuck to objects willy-nilly. You would have to ask for that behavior by having a variable or a parameter declared as that interface type, or explicitly casting to the interface type at the callsite (casting to interface is extremely rare in Go… casting is extremely rare in Go… but it’s legal, and makes sense here). That seems consensual enough to me, and it would work without any language changes besides allowing methods to be declared on interfaces.