Extract Payload for enum cases having associated value

I didn't say they were the same, but they are two sides of the same coin: one is the dual of the other, which is great! It gives us the opportunity to take every feature on structs and explore its dual on enums (and vice versa). There are a lot of fascinating examples!

It is true that enums are great for enumerating actions and events, but remember that Optional and Result are enums, and it's common to reassign mutable optional variables (which is data manipulation), or using map and flatMap to transform the associated value.

Even more app-specific enums, like those actions and events, have reason to be manipulated. If you have an AppAction enum that enumerates over smaller sets of actions for smaller screens, like case login(LoginAction) and case signUp(SignUpAction), etc., you could write isolated business logic for login actions, and isolated business logic for signup actions, but you could not compose that logic together generically to combine them into your application logic without enum key paths (or a lot of manual boilerplate).

I have to disagree. I think this is very much on-topic and deserves discussion given the dual nature of enums and structs. To ignore data manipulation in any design for data access would be a hugely missed opportunity.

9 Likes

I totally understand your point of view, but this was already discussed and rejected. To talk about it in this post will just cause also this one to have the same outcome. My pitch is just about accessing associated values which is a recurring question/request. I do understand that you like the idea of KeyPaths, and when I read it the first time I liked it too, believe me, I really did. Dangerous with unexperienced developrrs, but very powerful.
It is my understanding that the swift community already expressed their opinion on this topic, and I'm not trying to propose it again.
I'm asking you to critically and constructively analyse this proposal that is voluntarily different from yours, giving it a chance. I believe that having easier access to payloads would be a huge win, and I can tell this because of my experience using enums as Event in an Event Driven Architecture using MERLin.

Syntax becomes easy to read, very declarative and events are very easy to write and emit. I would like to see it supported natively.

I do see where the pitch is coming from, though I donā€™t thing the solution is apt.

The extract function assumes that all payloads of the same type is treated equal. It may be somewhat true for custom Payload, not necessarily so for common types like String, Int. One could claim that such access is ill-form (and even provide mechanism to prevent that), but what if we have CustomError that is type-aliased to Int?

Another problem is the evolution of (non-frozen) enum. The current (curated) extractible type may work, but adding improper case could potentially break it, and the author needs to check again if the current extractible type remains extractible. Itā€™s not exactly good for a feature that wants to be convenient.

Where was it rejected? The community feedback to my earlier pitches was almost entirely positive, just some bike shedding on details and no one with the time/knowledge to implement. There was no proposal that went through evolution to be rejected.

The compiler engineers also seem interested in closing the gap here, but it's not at the top of the list of things they need to work on.

2 Likes

Lantua, so far these functions served me well with any type of payload (even typealiased), but I understand the concerns.
Having these functions natively in the swift codebase we might exploit some compiler magic to make sure that cases won't break. We already have pattern matching. We should expand that idea and avoid the use of Mirror.

In this Pitch, I'm using it to explore effectively how the syntax would look like and see if we like it. The point is "we need easy access to payloads" rather than "we should access to payloads in this way".

How to achieve this result, is an implementation detail of which I just offered a possible solution that I'm sure this community can improve.

That's cool. then we should continue the conversation there. Here I'm proposing something different. :slight_smile:

1 Like

Yeah, Iā€™m talking purely about the syntax at usage point (aka function signature).

func extractPayload<Payload>() -> Payload?
func extractPayload<Payload>(ifMatches pattern: (Payload) -> Self) -> Payload?

ignores the case and positional information AFAICS, and thatā€™s the footguns I mentioned above. (That implementation could have unexpected result as well if there are multiple Payload, but as you said, thatā€™s not the point here)

We could potentially utilize the labeling information to see if we have the right Payload

func extract<Payload>(label: String?) -> Payload?

To differentiate

enum Foo {
  case bar1(payload: Payload)
  case bar2(partialPayload: Payload)
  case bar3(payload: Payload)
}

So that extract("partialPayload") will get only bar2

Though that raise the question of the treatment of unlabeled payload.

I like an even more concise signature, I don't like matching with string. Pattern matching worked well so far as it is strongly typed and compiler helped. Can I suggest you to copy paste the examples in a playground? I don't see how Payload could be ambiguous. Will actually update the first post with an even more concise method signature.

Changed on the first post.

This enum

enum Foo { 
    case bar1(String, and: String) 
    case bar2(payload: String) 
    case bar3(payload: Int) 
}

would be accessible with

let foo: Foo = ...
let value1 = foo.payload(matching: Foo.bar1) //Optional((String, String))
let value2 = foo.payload(matching: Foo.bar2) //Optional(String)
let value3 = foo.payload(matching: Foo.bar3) //Optional(Int)

I think this is a problem worth solving. It's unfortunate that case name overloads complicate this so much. I'm assuming it's too late to remove support for that, but I think we'd end up with a much cleaner model if we could.

I'd prefer to take the property synthesis approach, which I think is very clean and straightforward. Due to case overloads though, doing it properly would require supporting property overloads. Which I think is reasonable given that parameterless functions can be overloaded, but it complicates getting this proposal through. I still think it's the correct approach, though.

Another option that I think would be elegant if not for case overloads is having a synthesized, nested Case enum for payload-bearing enums, which has also been discussed here before. Like this:

enum Foo {
    case bar(Int)
    case baz(String)
    
    // Synthesized:
    
    enum Case {
        case bar, baz
    }
    
    var case: Case { ... }
}

Then you can do things like foo.case == .bar, foo.payload(for: .bar), etc. It's also useful on its own -- it's not uncommon in my experience to want to refer to enum cases in general without their payloads. But this seems to be a no-go given that there's no reasonable way to synthesize the Case enum when there are case name overloads.

3 Likes

Another problem I see with synthesised properties is the lack of filtering through collections and events stream. I obviously believe that pattern matching is the right way to go. It's not too invasive, it's type safe, strongly typed, the compiler is satisfied with no ambiguity and so far it supports all sorts of use cases

It would be nice to have a general mechanism for matching a pattern and producing an Optional result as an expression. One idea I had back in the early days of Swift was pattern matching closures, where a closure literal beginning with case and followed by a pattern would represent a function that matches its arguments, and returns a tuple of the variable bindings within the pattern:

let right: (Result<Int, Error>) -> Int? = { case .success(let x) }
let left: (Result<Int, Error>) -> Error? = { case .failure(let x) }

let leftAndRight: (Result<Int, Error>, Result<String, Error>) -> (x: Int, y: Error)?
   = { case (.success(let x), .failure(let y)) }

This would allow patterns to be used as values that could be passed into regular higher-order functions like compactMap. If we eventually get variadics that can automatically pack and unpack tuples into arguments, it could also allow for library functions that get pretty close to the expressivity of the hardcoded if case/for case syntax. It admittedly still isn't terribly convenient for the common case of wanting to get at an enum payload, though.

Seems like a perfect use case for kotlin's sealed class. Curious if Swift's enum could add a similar feature.

Anyway, I think the bigger issue this tries to address is the awkward ergonomics of accessing associated values outside of a switch.

currently:

if case let .foo(payload) = yourCase { 
    // use payload
}

is relatively concise, but feels weird. As much as I've used it, case just doesn't feel right in if. It's also completely unclear what's being created or unwrapped, and there's essentially 0 code completion possible since the left side of the operation doesn't have a Type until the right side is written.

It's obtuse enough that I tend to just use switch, even when checking only for one value.

if let payload = yourCase.extractPayload(ifMatches: .foo) {
    // use payload
}

Isn't more concise, but I do think it's ergonomically better.

However I have 2 concerns.

  1. I don't see a need to introduce the idea of a "Payload". IMO it doesn't really describe the relationship, and we already have a name for this data: "associated value".
  2. Anything baked into enums that's optional seems very non-swift-like. Getting a generic-optional value for an enum instance's associated values should be something that's possible to do (unlike now), but I would much rather see enums improved to make this easier for users to add, rather than being added to the language itself.

What I would prefer is using existing naming and be spelled similar to a typical if let or Type casting:

//given 
enum Foo { 
    case bar1(firstValue: String, secondValue: String) 
}
// for full payload:
if let payload = (yourCase as? .bar1).associatedValues {
    //payload is (String, String)
}
// or for a specific value:
if let payload = (yourCase as? .foo).secondValue {
    // payload is String
}

I think I even tried to do something like this when first learning the language. Using parens isn't ideal, but I would much prefer that kind of general spelling.

I'm not exactly sure how this would work internally. I think it would require enum cases to function more like a Type than (at least externally) they currently do, and my guess is properties would be generated only for enum cases that have associated values. How hard that is to do is beyond my knowledge base. I could also see it being opt-in with a protocol similar to CaseIterable.

3 Likes

I like it. In my first pitch I called it EventProtocol as I need it for a very specific use case. What the name would be in a broader context?

For the "payload" naming, how about just value?

let payload = foo.value(matching: Foo.bar)
Or also, following the as example
let payload = foo.as(case: Foo.bar)

What's the problem? You can compactMap both, no?

As someone on the front lines of the key path implementation, do you see a path forward for providing similar hooks for enums?

If something can be expressed as a function, it ought to be usable in a read-only key path, at least. Mutating through key paths would require expressivity the language doesn't have in general, to be able to express mutation to destinations that may or may not exist.

2 Likes

value seems too similar to rawValue.

I haven't thought of any good protocol names. AssociatedValueAccessible comes to mind, but seems unnecessarily verbose.


protocol CaseAccessible {
    var label: String { get }
    
    func associatedValue<AssociatedValue>() -> AssociatedValue?
    func associatedValue<AssociatedValue>(mathing pattern: (AssociatedValue) -> Self) -> AssociatedValue?
}

extension CaseAccessible { // assuming we can constraint these default just to enums thanks to compiler magic
    var label: String {
        return Mirror(reflecting: self).children.first?.label ?? String(describing: self)
    }
    
    func associatedValue<AssociatedValue>() -> AssociatedValue? {
        return decompose()?.value
    }
    
    func associatedValue<AssociatedValue>(mathing pattern: (AssociatedValue) -> Self) -> AssociatedValue? {
        guard let decomposed: (String, AssociatedValue) = decompose(),
            let patternLabel = Mirror(reflecting: pattern(decomposed.1)).children.first?.label,
            decomposed.0 == patternLabel else { return nil }
        
        return decomposed.1
    }
    
    private func decompose<AssociatedValue>() -> (label: String, value: AssociatedValue)? {
        for case let (label?, value) in Mirror(reflecting: self).children {
            if let result = (value as? AssociatedValue) ?? (Mirror(reflecting: value).children.first?.value as? AssociatedValue) {
                return (label, result)
            }
        }
        return nil
    }
}

Then things like Array extension would be a natural possibility

extension Collection where Element: CaseAccessible {
    func filter<AssociatedValue>(case pattern: (AssociatedValue) -> Element) -> [Element] {
        return filter {
            $0.associatedValue(mathing: pattern) != nil
        }
    }
    
    func map<AssociatedValue>(case pattern: (AssociatedValue) -> Element) -> [AssociatedValue] {
        return compactMap { $0.associatedValue(mathing: pattern) }
    }
    
    func exclude<AssociatedValue>(case pattern: (AssociatedValue) -> Element) -> [Element] {
        return filter {
            $0.associatedValue(mathing: pattern) == nil
        }
    }
}

How would it look like:

enum ProductDetailEvent: CaseAccessible {
    case loaded(Product)
    case selectedSku(String, forProduct: Product)
    case changedSize(Size)
    case changedColor(Color)
}

let loaded = ProductDetailEvent.loaded(Product())
let selected = ProductDetailEvent.selectedSku("1234", forProduct: Product())
let changedSize = ProductDetailEvent.changedSize(Size())
let changedColor = ProductDetailEvent.changedColor(Color())
let loadedAnotherProduct = ProductDetailEvent.loaded(Product())

func didReceiveEvent(event: ProductDetailEvent) {
    let sku = event.associatedValue(case: ProductDetailEvent.selectedSku)?.0 //String?
    // or
    guard let (sku, product) = event.associatedValue(case: ProductDetailEvent.selectedSku) else { return } 
    // or
    guard let payload: (String, Product) = event.associatedValue() else { return }
}

let events: [ProductArrayEvent] = [
    loaded,
    selected,
    changedSize,
    changedColor,
    loadedAnotherProduct
]

let selectedEvent = events.filter(case: ProductDetailEvent.loaded).first
let loadedProducts = events.map(case: ProductDetailEvent.loaded) // [Product]

The example with array is relatively interesting, compared with the utility that would come extending AnyPublisher (or Observable for Rx) with such functionalities for pattern matching.

I'm trying to wrap my head around the pitch, but I'm failing to see how this is better than making the compiler synthesize get-set properties for enum cases.

I use Sourcery to automatically generate properties for my enums and my life couldn't be easier.

For example, from this enum:

enum Location {
  
  case notRequested(shouldRequest: Bool)
  case denied(restricted: Bool)
  case granted(Availability)
  
  enum Availability {
    
    case always
    case whenInUse(shouldRequestAlways: Bool)
  }
}

I generate this:

extension Location {
  
  var notRequestedWithShouldRequest: Bool? {
    get {
      guard case let .notRequested(x0) = self else { return nil }
      return x0
    }
    set {
      guard let newValue = newValue, case .notRequested = self else { return }
      self = .notRequested(shouldRequest: newValue)
    }
  }
  
  var isNotRequested: Bool {
    guard case .notRequested = self else { return false }
    return true
  }
  
  var deniedWithRestricted: Bool? {
    get {
      guard case let .denied(x0) = self else { return nil }
      return x0
    }
    set {
      guard let newValue = newValue, case .denied = self else { return }
      self = .denied(restricted: newValue)
    }
  }
  
  var isDenied: Bool {
    guard case .denied = self else { return false }
    return true
  }
  
  var granted: Location.Availability? {
    get {
      guard case let .granted(x0) = self else { return nil }
      return x0
    }
    set {
      guard let newValue = newValue, case .granted = self else { return }
      self = .granted(newValue)
    }
  }
  
  var isGranted: Bool {
    guard case .granted = self else { return false }
    return true
  }
  
}

extension Location.Availability {
  
  var always: Void? {
    get {
      guard case .always = self else { return nil }
      return ()
    }
  }
  
  var isAlways: Bool {
    guard case .always = self else { return false }
    return true
  }
  
  var whenInUseWithShouldRequestAlways: Bool? {
    get {
      guard case let .whenInUse(x0) = self else { return nil }
      return x0
    }
    set {
      guard let newValue = newValue, case .whenInUse = self else { return }
      self = .whenInUse(shouldRequestAlways: newValue)
    }
  }
  
  var isWhenInUse: Bool {
    guard case .whenInUse = self else { return false }
    return true
  }
}

This is incredibly useful, allows the compiler to generate the proper KeyPaths, and the setters make working with enums a pleasure. Even the is_ properties are really useful in many cases, because case is unfortunately not an expression.

It would be glorious if case was an expression, I can't count the times when enums gave me headaches at usage site. That's mostly why I'm generating those properties.

2 Likes