Multiple Types

Love to be able to do something like this:

var variable: String || Int
func doSomething() -> String || Int

The idea is that rather than using any you can keep better type constraints but be able to use a variable/return/etc. that has multiple constrained types.

Hi @JCKL, this is a commonly rejected change:

  • Disjunctions (logical ORs) in type constraints: These include anonymous union-like types (e.g. (Int | String) for a type that can be inhabited by either an integer or a string). "[This type of constraint is] something that the type system cannot and should not support."
8 Likes

That's almost the same, no?

enum E {
    case string(String)
    case int(Int)
}
5 Likes

That’s an interesting option. Thanks!

Can you explain where this might be useful?

Swift currently supports tuples like (s:String, i:Int) which may also do what you need. When combined with optionals, you could mark which return parameter(s) are valid.

I'd love if the Core Team and/or the Language Steering Group reconsidered the absolute rejection of anonymous union types for Swift. Several modern languages are adopting them, they are objectively useful in many cases and, just to think of an example in Swift, they would enable ergonomic typed throws. For example, Richard Feldman in this recent talk shows how they can be leveraged for representing functional effects.

12 Likes

Swift has support:

Types Product Sum
Named struct enum
Anonymous tuple n/a

For the sake of completeness it would be nice to have support for ad-hoc anonymous sum types.

13 Likes

People requesting this feature should be careful what they wish for. I had extensive experience with anonymous sum types in TypeScript, and they effectively tend to make error messages completely unreadable. Good luck quickly finding a reason why your A | B | C | D | E value can't be passed to a function that expects A | B | G | D | E type. And I'm only using a single-letter types here to quickly write it out, in practice types have much longer names and can be generic with an arbitrary level of nesting (e.g. with result builders).

Completeness should be always considered in the context of usability. Supporting something only because it allows us to cross off n/a in some table just for the sake of it makes no sense if it tends to make developer experience worse on balance.

19 Likes

Isn't that also true for tuples? I think if the types get sufficiently large, people will tend to create named types instead, just as large tuples, morph into structs as they mature, grow, or become used in many places.

Especially for single-use function arguments, it would often be useful for anonymous enums, methinks.

6 Likes

One of my highest voted answers on StackOverflow is exactly about this, on the question Swift function returning two different types. The answer isn't entirely wrong, but it's close.

I gave everyone the enum workaround, but it feels like I just gave out footguns. Strings and integers are almost nothing alike. A function that returns one or the other is really weird, and almost certainly indicative of a more basic data modelling issue that underpins it.

Almost every time I've seen someone reaching for union types (because it's a pretty natural spot to look for it), they had a situation that would be much better fixed by some combination of:

  1. Picking a single canonical data representation, and making everything else bridge to/from it.

    • For example, I saw someone body once wanting to model a calculator, whose operations could operate on either strings or integers. E.g. they wanted:

      // FIXME: this make *no* sense.
      func add(_ a: String | Int, _ b: String | Int) -> String | Int { ??? }
      

      This makes no sense. Arithmetic works on numbers. Parse your strings into numbers, work with the numbers. Then go back to strings if you need.

  2. Model the operations your return type needs to support, and represent them with a protocol. Anything that can conform to the protocol can work with it.

    This is especially great, because the protocol clarifies exactly what it is the various possible return types have in common. "What do String and Integer have in common, and why does this API say they want either?" versus "Oh, this returns anything that has a var description: String.

I think people have been reaching for this more since TypeScript got more popular, and I have to say, even in TypeScript, which has union types, they massively overused and cause some crazy code.

7 Likes

Doesn't an Either type satisfy that need?

I guess if there was an Either type in the standard library, I might use it, just because it's there. But it always felt kinda awkward, and the case names are often not useful, like .left and .right. If I have to write the type myself, I might as well just create single-use ad-hoc enums for each situation. For function arguments, I might sometimes just create function overloads.

There are lots of other solutions and possible workarounds. I can design my way around it, create custom named types. No doubt. And as with every general tool, it can certainly be both used and misused.

But still, I think it would be nice to have the option to use anonymous sum types.

3 Likes

Could someone please steelman the case for when this might be useful, that wouldn't be better expressed with protocols?

The original example of func doSomething() -> String || Int isn't particularly compelling, without a better understanding of what doSomething might be that's reasonably implemented by returning a string or integer.

7 Likes

One idea I’ve had that might make anonymous type unions (potentially) reasonably implementable is to require the user to provide the desired upper bound on the types. That is while:

T | U | ...

would not be a type,

(T | U | …) as V

where one can as cast from each of T, U, etc. into V, would be a type with the ABI of V and API of being composed solely of values which could have been derived from values of types T, U, etc. by as casting.

However, I haven't come across any occasions where this would be useful, and thus have not developed it further.

Sure: Optionals.
It is possible to model Type? as Type|Null ( Ceylon (programming language) - Wikipedia did it that way).

Note that this choice has implications at the type level, which cannot be achieved with enums or an either-type — and those resolve some significant exceptions which Swift had to include for its optionals.

1 Like

Unless I'm missing something, Type|Null can't represent nested optionality and as such wouldn't be able to substitute for Swift's Optional type.

4 Likes

That is true, but I don't see the difference as a downside (as it is unlikely that a 100% compatible reimplementation will ever be required).
There is no real need for nested optionals, and afaics, in real world context, they are just annoying; see SE-230 for an example.

Is it not possible to have (Type | Null) | Null ?

We could do this currently:

var dict: [String: String?] = ["hello" : nil]
if let element = dict["hello"] {
    if let value = element {
        print("has key and it is \(value)")
    } else {
        print("has key and it is nil")
    }
} else {
    print("has no key")
}

Without nested optionals I guess we'd rewrite it to smth like:

if dict.hasKey("hello") {
    if let value = dict["hello"] {
        print("has key and it is \(value)")
    } else {
        print("has key and it is nil")
    }
} else {
    print("has no key")
}

which doesn't seem a big deal. What other use cases we now have that require nested optionals and are there any show stoppers (i.e. something we can do now but it'll be impossible or too hard to do without nested optionals?)

That would be Type | Null | Null, thus Type | Null.
Of course, you could still have something like Either<Type, Null> | Null, but I don't think that is desirable.
In the case of dictionary, I'd prefer a return type like Missing | Value which would result in Missing | Type | Nil when you really want to store Type?.

SE-230 is a bit of an exception, but there absolutely is a real use for optionals. ("Need", not so much. But there's no "need" for if statements and for loops either, you could just use goto)

Nested optionals can model the difference between "There is no value for your key in this dictionary" and "There is a value for your key, and it itself is actually nil".

As you point out, that can be modelled with Missing | Type | Nil as you suggested, but that's pretty much the JS solution of having null and undefined. It leads to some weird edge cases, and is pointlessly artificially limited in a way that the nesting/composability of optionals isn't

4 Likes