Structural Sum Types (used to be Anonymous Union Types)

Spinning off a new discussion thread on the topic of anonymous union types from the Status check: Typed throws thread.

The spin-off discussion on this topic began somewhere around this comment.

3 Likes

I think it worth to take a look at Hylo's sum types as a source of inspiration
https://docs.hylo-lang.org/language-tour/basic-types#unions
https://github.com/orgs/hylo-lang/discussions/723

4 Likes

Would a type that’s an existential of an ad hoc protocol for which the combined types get implicit conformance meet the design goals?

e.g.:

var union: (Int | String) = "foo"
union = 4

//union is an existential of a compiler-created marker protocol for which both Int and String get compiler-created conformances

Would that work?

I strongly believe that this is the wrong way to go, as it skirts around the type safety that is one of Swift's strengths.

I actually don't think anonymous sum-types/unions/enums are a very necessary feature for Swift, but if they were to be implemented, I would hope it would look more like:

var union: (Int | String) = .1("foo")
union = .0(4)
5 Likes

I don't think anonymous unions should be related to protocols and existentials at all. The whole point of an anonymous union is to provide static polymorphism. A protocol, on the other hand, is providing dynamic polymorphism. I think that's a completely unrelated concept.

Without addressing syntax bikeshedding or sugar, this is pretty much exactly what I had in mind under the hood.

5 Likes

The arity informing the cases implies that (Int | String) and (String | Int) would be different types, which feels wrong.

8 Likes

Could you please please expand on this?
In my mind this is the way to preserve strictness of the current type model, while reduce the boilerplate of enum cases. (because you can assign a concrete value to a variable of a union type, but not vice versa)

same is true of tuples. I can't upvote June Bash's comment enough. For me this entire debate boils down to this, I would like to be able to write all of the following three functions variadically.

enum Either<Left, Right> {
    case left(Left), right(Right)
}

func compose<A, B, C>(
    _ f: @escaping (A) -> B,
    _ g: @escaping (B) -> C
) -> (A) -> C {
    { a in g(f(a)) }
}

func universalProduct<A, B, C>(
    _ f: @escaping (B) -> A,
    _ g: @escaping (B) -> C
) -> (B) -> (A, C) {
    { b in (f(b), g(b)) }
}

func universalSum<A, B, C>(
    _ f: @escaping (A) -> B,
    _ g: @escaping (C) -> B
) -> (Either<A, C>) -> B {
    { either in
        switch either {
            case let .left(a): return f(a)
            case let .right(c): return g(c)
        }
    }
}

The main thing that is keeping me from doing that is that I have to use a nominal type in the third one rather than a structural type. I'm tired of writing arity-versioned functions on every place where I need to compose functions.

3 Likes

Exactly. This is not some new revolutionary concept that would enable Swift to handle completely new kinds of types and type relationships. It's merely filling in an ergonomics gap in the language pertaining to functionality that is already in the language. As I mentioned before, this is as useful to have in light of enums as having tuples in light of structs.

1 Like

couldn't agree more. If there were a structural notation for a sum type corresponding to Either and higher-arity friends and if that notation exactly corresponded to what we already do with tuples in an intuitive way, it is hard to see how the type inference becomes harder than it already is for the nominal types that can already be inferred. And that's why I think @JuneBash 's notation above seems exactly correct.

1 Like

I even suggested an alternative to .0 and .1 in the previous thread. It is about type-based case discrimination. It even explains what happens if the two types are the same. One open question I have is: since tuples can have optional labels, should a union have optional labels too? If so, how would it look?

If so, the type-based discrimination could be merely syntactic sugar (especially in light of destructuring akin to how you can declare a variable cluster and assign a single tuple to it).

But I would rename the title of this thread: "Structural Sum Types" instead of Union types as I tend to agree with this definitional distinction

But, it is also the case that this has been turned down many times before and I expect this time to be no different. But it's always good for a centi-thread.

Good point! I renamed it.

It's not the first instance of a rejected pitch being reconsidered in light of massive amount of evolution that the language and the community underwent since the rejection. We have things like macros and parameter packs now. Those can completely change the perception of what looks like a reasonable language feature or a reasonable API design decision.

This thread is currently talking about both coalescing unions with subtyping (a significant type system enhancement) and simple anonymous sums (syntactic support analogous tuple syntax for Either-like types). It would be best to distinguish them very clearly in this thread. I suspect anonymous sums have a much higher chance of being accepted and recommend focusing on this.

15 Likes

I suspect anonymous sums have a much higher chance of being accepted and recommend focusing on this.

Perhaps in light of the introduction of variadic generics you are correct. I hope so...

1 Like

Great callout! To be clear, having automatic coalescing was never something that I wanted (I may have briefly assumed that it was necessary, but I was shown that it's not the case). I always expected the sum type to be manually unpacked. Even tuple splatting was removed from the language in order to make them a pure type with no magical behavior.

All I wanted was something like this:

switch mySumTypeInstance {
    case let int as Int: // ...
    case let string as String: // ...
    // no default, all cases are handled
}

No switch - no decomposition.

By the way, this would greatly benefit from the new switch expression feature!

EDIT:

Any code that has to do with result builders would most probably immediately benefit from this.
Especially with talks of enabling extensions on tuples, we could also have extensions on unions (I'm calling them union for now because this name is more ergonomic to refer to and is more ubiquitous).

Following tuple syntax it would look more like:

switch mySumTypeInstance {
    case let .0(int): // ...
    case let .1(string): // ...
    // no default, all cases are handled
}

And perhaps the .0 and .1 could have label aliases like tuples, but that’s an enhancement it’s not essential.

5 Likes

As long as this is fully variadic, I can live with .0 instead of as Type. It's the functionality that counts. Any extra syntactic sugar could be added after the base feature is finished (if needed at all). Besides, having optional labels would be very helpful.

Personally, one thing that bugs me when implementing yet another Either type is that I have to give each case a name. There can never be an objectively good name for its cases. It's neither left and right, nor first and second. It's .0 and .1 at best (with the as Type syntactic sugar trying to remove even those, but I'm starting to think that removing them might not be as beneficial as it felt to me).

EDIT:

What I would like to have though, is an equivalent of tuple destructuring:

let (key, value) = dictionary[index]

This is a perfect example of taking advantage of the structural nature of a structural type. This allows quickly dissolving a tuple into concrete workable values.

I'd like to have an ability to quickly dissolve a union into a concrete workable value as well. Not sure how that would look, though. A switch expression may end up being the only way to do this ergonomically. Just something to consider.

What would this look like, though? I don't think it's possible to make a one-to-one equivalent. And actually, I believe we already have the closest thing we could have to an analogue:

let foo: (String | Double) = .0("bar")

switch foo {
case .0(let str): print(str)
case .1(let num): print(num)
}