Extract Payload for enum cases having associated value

If it were up to me, I'd overhaul enums completely. The model I want is this:

  enum MyEnum {
     case 
          foo(label:String, Int),
          bar(Double),
          baz
  } 

Usage

  let 
     a: MyEnum = .bar(123.0),
     cpy: MyEnum = a, // .bar(123.0) case and its payload
     val: Double = a() // 123.0 payload only

  let 
     b: MyEnum = .bar(123.0),
     c: MyEnum = .bar(456.0),
     d: MyEnum = .baz

   a == b // true (both are 'bar')
   a === b // true (both 'bar' and same payload)

   a == c // true
   a === c // false  (different payload)

   a == b // false
   a === b // false

I have essentially been using this model (via structs), and it works well. The trouble with using a struct is that the declaration of the struct involves a lot of code repetition (you have to declare both a static var and a static function overloaded with the same name for every 'case') and wasted storage for the backing id. Also, if it were built-in, Swift could warn you, when you, for example, try to compare two cases with payloads of different types.

When a is baz a() should return Void, therefore there is no way that val could be declared as Double. The type of an associated value in an enum is uncertain. All you know for a is that it is a MyEnum which is not enough to guarantee that val will be Double.

If I remember correctly, in this thread we didn't reach an agreement on the ergonomics. But I've bundled my solution in EnumKit and I've been using that happily since then in combination with Combine and RxSwift code. Until we get something in the language, this is so far all I need.

1 Like

Why "should" it work that way? It's a design choice (and simple to implement, thanks to "@dynamicCallable").

With the paradigm I like, the programmer doesn't call the enum value with parens, unless they want its payload. Since the Payload is stored as "Any", we need to specify its type when we retrieve it. I'd also be fine if calling via parens provides an optional:

val: Double? = a() // nil, unless the Payload type matches

In practice, I find this matches what I actually need to achieve when I use an enum with a payload. That's opposed to the way Swift works currently, which... I guess it works in theory? It certainly is unwieldy and strange, in practice, for nearly every situation where it is supposed to be useful.

Note that, the "struct as enum" system I have been using, does understand the payload type, when the programmer creates an enum — and, with some tweaks to Swift — the Swift compiler could use that information when retrieving the payload, too.

PS: I haven't checked out EnumKit yet. I suspect I also will find it far superior to what we currently have in Swift.

let a: MyEnum = .baz // No payload
let val: Double = a() // Not legit. `a()` can be Double only if `a` is `.bar(xyz)`

This is what I'm trying to say. you can't have an honest Double from a variable of type MyEnum because you can't know apriori if:

  1. There is an associated value to extract.
  2. The associated value is in fact of type Double and not (String, Int) for example if a was .foo.

True, Because enum cases would now do double-duty: when used without arguments they refer to their family (eg: .baz) and when used with arguments, they more-or-less refer to an instance (eg: baz(123.0) ).

If we make .a() an optional, how big a problem is this? To provide the same functionality with Swift at present requires a lot of code (eg: a second enum, custom getter, then using switch statements and "if let case" nonsense).

I think this might be a problem in theory, but a non-issue in practice. Even now, an enum with a payload in Swift is already basically a type. Whether we use existing Swift enums, or the paradigm I like, the programmer needs to know about the specific case, and the type(s) of its payload.

I should also point out, that these problems are those that occur using my hacky enum-like structs in Swift, as it exists now. Even so, I find them minor — if they were built right into Swift, the compiler could, for example, type check the second example live. Maybe even the first example could be solved with some sort of magic.

To make things less abstract, in practice, we're looking at code, at the call-site, like:

// if enum value is a .magnitude, add it to an array of Doubles
if measurement == .magnitude { 

    doubles.append( measurement() ?? 0.0 )

}

or

// filter an array of enum cases to contain only those that are .magnitude, regardless of their custom payload
let magnitudes = readings.filter{ $0 == .magnitude }

I'm not sure if those examples do it justice. I currently use this for getting and setting class parameters, and, at the call-site, it's really nice. Declaring the things, even with a protocol doing most of the work, is still ugly, because it requires a lot of duplicated code (each payload case needs both a var and a func).

let a: MyEnum = .bar(123.0)
let c: MyEnum = .bar(456.0)

a == c   // true (same case)
a === c  // false (different payloads)

Right now, enumeration equality considers the case and payload. So your idea above, making case-only equality the default and bumping deep equality to a side operator, is 100% source breaking right off the bat! And it breaks Equatable. What about types that don't (want to) have equality operators defined? And I think that new users would rarely want case-only comparisons, let alone them out-prioritizing conventional comparisons.

Case-only comparisons should use a function that extracts the tag:

assert(case(of: a) == case(of: c))

Or maybe a "#caseOf" built-in function. It would be a complement of the

a is case .bar(_:)

idea I've had. We would need to add a standard library type to represent enumeration case tags. Could key paths be extended for this?

1 Like

I think it's a little bit of each, but maybe the third weighs most heavily. A few of us are very interested in fleshing out a proposal, but we'd need a compiler engineer to get an implementation. The reflection mechanism in EnumKit inspired the reflection mechanism in swift-case-paths, and folks have joked that "swift-case-paths" could suffice as the "implementation" of an evolution proposal, but without real compiler support I think it'd be a tough sell.

These case paths have already proven to be very useful in "real world," production applications:

  • They are used as a tool for composing the fundamental unit of applications built using swift-composable-architecture. They give developers the ability to break down large, complex applications into smaller ones and then glue them all back together using key paths and case paths.

  • They can be used to derive mutable SwiftUI.Bindings from enum state. Currently, enum state is so cumbersome in SwiftUI that you are typically led to model your app's domain in ways that introduce invalid states and bugs.

A few unanswered "future direction" questions:

  • What does "dynamic member lookup" look like for case paths?
  • How should "key paths" and "case paths" compose? They form their own structure, which models optional chaining:
    // optional get (User) -> String?
    user.location?.name
    // required set (String) -> User
    user.location?.name = "LA"
    // 🛑 error: 'nil' cannot be assigned to type 'String'
    user.location?.name = nil
    
    This means we must also consider a third "optional path" of sorts and how they would fit into the "hierarchy" of paths is uncertain.
7 Likes

If this is an issue, it can be handled as we do elsewhere, by making it dependent on conforming to a protocol, like Equatable or Hashable

Re: beginner expectations, note that the changes I propose only impact payload enums. I doubt beginners are able use those at all.

Oops. Yes, that's a dose of reality. I suppose if I continue to advocate for my paradigm (wish I had a less annoying word than "paradigm", btw), I should switch to proposing it as an additional kind of datatype or feature, that is like, but not a replacement for, existing Swift enums. And that's unlikely to happen, because Swift is complicated enough, as it is :(

Okay, I'll stop derailing the thread now, and focus my efforts on coming up with a proposal that doesn't aim to replace enums, but rather adds an additional protocol and magic, along the lines of RawRepresentable.

One thing that I think may be doable is a shorthand for extracting a payload as an optional if the enum has some given case. All the ways I can think to write it are kind of overly verbose:

If-case:

let payloadIfSomeCase: PayloadType?
if case .someCase(let payload) = enumValue {
  payloadIfSomeCase = payload
} else {
  payloadIfSomeCase = nil
}

Switch:

let payloadIfSomeCase: PayloadType?
switch enumValue {
case .someCase(let payload): payloadIfSomeCase = payload
default: payloadIfSomeCase = nil
}

In both of these situations, you need to write the payload type out manually. The payload extraction happens inside a new scope, so to hoist it out in to a local variable, you need to declare that variable before-hand, meaning you don't get type inference. You also need to either handle the else condition or make the variable mutable rather and give it a default value of nil.

Perhaps something like:

case? .someCase(let payloadIfSomeCase) = enumValue
payloadIfSomeCase?.someFunction()

Which is almost exactly the same as the line in the switch statement above, mixed with tuple destructuring:

(let x, var y) = someFunctionReturningATuple()
x.someFunction()

Something that is small and targeted like this would, IMO, have a higher chance of being accepted than some of the more creative solutions I've seen floating around, which would often involve lots of code generation, reflection, or other magic abstractions.

1 Like

We could use something like key paths to represent lenses/prisms.

enum MyEnum {
    case first
    case second(Int, Double)
}

let a = MyEnum.second(5, -6.5)
let b = a[keyPath: \.second]   // should be a "(Int, Double)?"
let c = a[keyPath: \.second.1] // should be a "Double?"
let d = a[keyPath: \.first]    // should be a "Void?", which will be NIL here
9 Likes

I came here to propose exactly this. It's short, natural and familiar.

1 Like

So far (correct me if I'm wrong on this) key paths don't have any special syntax exclusive to them. When you can write a[keyPath: \.x] you can also write a.x, no matter what x actually is (I guess you have to drop the dot for subscripts, but you get my point.

I don't think it would be a good idea to change this and have special features that are only available via key paths, so imo it would be necessary to have the same variables available on the enum value itself.

This syntax also would need to be extended to handle enums where cases are distinguished only by parameter labels:

enum Lol {
  case foo(s: String)
  case foo(i: Int)
}

let a = Lol.foo(i: 42)
let b = a[keyPath: \.foo] // what is this?
1 Like

x can't be methods, applied or unapplied:

struct A {
    func foo(i: Int) { }
}

\A.foo // Error
\A.foo(i: 3) // Error

Yes. I'm not saying "a.x works" implies "a[keyPath: \.x] works", just the reverse.

2 Likes

It would be akin to a key path. The "\MyType.myCase" format looks just a nice as it does for actual key paths. We can call the subscript "case:" instead. Hopefully, we can make the parser accept "\MyType.myCase(myFirstPayload: mySecondPayload:)" syntax.

I personally think this is an interesting syntax to extract enum payloads.

To correctly infer the right payload-tuple type, the enum case needs to be passed in some way. You suggested to use myEnumInstance[keyPath: \.myCase]! to access the associated values, but that would require your Enum to conform to a new protocol for API stability.
On the other hand, as? case or as! case would work without new requirements.

I'm interested in having analogies between enums and optionals (which are enums):

let a: Int? = 3

// unwrap value in an optional
if let a = a { ... }
if let a = a as? Int { ... }
// force unwrap value in an optional
let b = a!
let b = a as! Int

// unwrap values in an enum
if case let .some(x) = a { ... }
if let x = a as? case .some { ... } // new syntax, x is Int
// force unwrap values in an enum
let x = a as! case .some            // new syntax, x is Int

Enum force unwrapping would then be chainable as optional force unwrapping:

enum Enum1 { case .foo(Int, String) }
enum Enum2 { case .bar(Enum1) }

let a: Enum2 = .bar(.foo(3, "three") }

let (num, string) = (a as! case .bar) as! case .foo // num is Int, string is String
2 Likes

I propose using $ sigil. Although property wrappers use it, enum can't have a wrapper. So this should not break anything.

enum E {
  case foo(a: Int, b: Int)
  case bar(String)
  case baz
}

/* synthesized */ extension E {
  var $foo: (a: Int, b: Int)? { ... }
  var $bar: (String)? { ... }
  var $baz: ()? { ... }
}

func test(e: E) {
  if let value = e.$foo {
    print(value.a)
  }
}
3 Likes

One could theorize what "case" wrappers might look like, though...

enum E {
  @Foo
  case foo(a: Int, b: Int)
}

if case let $foo(wrapper) = e { ... }

So it might be prudent to avoid overloading the meaning of $ for now.

2 Likes

That would be great.
If that would be accepted I would say that people most of the time would use this instead.

if case .foo(let x) = myFoo {
if let x = myFoo.$foo {

More compact, more readable.
Also it can be used in many other ways without manual query var properties that we use to write currently to get the same effect.

KeyPath on the other hand would be useful too.

I would also accept if we would need to add a protocol like the CaseIterable. In this case it could be something like CaseQuerable.
It would not compile in case of the enum that has more than one case declared with the same name.

And KeyPath would work also only with an enum that implements CaseQuerable