This is a long requested feature that never saw the light. We have memories of other proposals pitched with Automatically derive properties for enum cases and before with Pitch: Even Smarter KeyPaths? - #2 by Joe_Groff from @stephencelis
How to access an associated value of an enum case is a very recurring question on stackOverflow:
Also on this forum the question appeared with a specific and more than valid use case by @zoul :
With the arrival of Combine I feel it's the right moment to pitch the proposal again, offering a different approach than the already proposed synthesised vars and smarter KeyPaths.
I personally liked what was alternatively proposed in this answer from the previously mentioned pitch, I in fact used this approach in my reactive framework MERLin to define an Event as enum and extract its associated value (if any) in a stream of events.
This allows me to do something like
// Given an Event Type
enum HomeEvent {
case loaded
case selectedProduct(Product)
case favoritedProduct(Product)
}
// And an Observable of events
let homeEvent: Observable<HomeEvent> { return _homeEvent.asObservable() }
let _homeEvent = PublishSubject<HomeEvent>()
...
func didSelect(item: Int) {
let product = products[item]
_homeEvent.onNext(.selectedProduct(product))
}
...
Observing the occurrences of an event would be as simple as
homeEvent
.capture(event: HomeEvent.selectedProduct) // custom operator using `associatedValue(matching:)`
.subscribe(onNext: { product in //product is of type Product
...
}.disposed(by: disposeBag)
// While this is RxSwift, this use case applies to any reactive framework
// including Combine
In MERLin I use Mirror
to extract the payload of any enum conforming EventProtocol
.
I believe that similarly to CaseIterable
, these functionalities should be accessible on conformance to a protocol.
CaseAccessible
public protocol CaseAccessible {
var label: String { get }
func associatedValue<AssociatedValue>(mathing pattern: (AssociatedValue) -> Self) -> AssociatedValue?
mutating func update<AssociatedValue>(value: AssociatedValue, matching pattern: (AssociatedValue) -> Self)
}
Just like it happen for CaseIterable
, a default implementation of these functions should be synthesised by the compiler for enums with at least one case having associatedValue
.
It is currently possible to try this solution in a playground using Mirroring:
public extension CaseAccessible {
var label: String {
return Mirror(reflecting: self).children.first?.label ?? String(describing: self)
}
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
}
mutating func update<AssociatedValue>(value: AssociatedValue, matching pattern: (AssociatedValue) -> Self) {
guard associatedValue(mathing: pattern) != nil else { return }
self = pattern(value)
}
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
}
subscript<AssociatedValue>(case pattern: (AssociatedValue) -> Self) -> AssociatedValue? {
get {
return associatedValue(mathing: pattern)
} set {
guard let value = newValue else { return }
update(value: value, matching: pattern)
}
}
subscript<AssociatedValue>(case pattern: (AssociatedValue) -> Self, default value: AssociatedValue) -> AssociatedValue {
get {
return associatedValue(mathing: pattern) ?? value
} set {
update(value: newValue, matching: pattern)
}
}
}
Overloading cases are currently correctly handled by the compiler that gives an error asking for more context, if needed.
enum Foo: CaseAccessible {
case bar(int: Int)
case bar(str: String)
}
let baz = Foo.bar(int: 10)
let value = baz.associatedValue(matching: Foo.bar) // ERROR: Ambiguous use of 'bar'
let value: String? = baz.associatedValue(matching: Foo.bar) // nil
let value: Int? = baz.associatedValue(matching: Foo.bar) // Optional(10)
// or with subscripts
let value: Int? = baz[case: Foo.bar] // Optional(10)
let value: Int = baz[case: Foo.bar, default: 0] // 10
The compiler presently works well also when the overloaded case has same type
enum Foo: CaseAccessible {
case bar(int: Int)
case bar(int2: Int)
}
let baz = Foo.bar(int: 10)
let value = baz.associatedValue(matching: Foo.bar) // ERROR: Ambiguous use of 'bar'
let value: String? = baz.associatedValue(matching: Foo.bar) // ERROR: Cannot invoke 'associatedValue' with an argument list of type '(matching: _)' (because there is no bar having a String as payload
let value = baz.associatedValue(matching: Foo.bar(int:)) // Optional(10)
let value = baz[case: Foo.bar(int:)] // Optional(10)
let value = baz[case: Foo.bar(int:), default: 0] // 10
Working with array or observables of enums then becomes really simple
enum Foo: CaseAccessible {
case bar(String)
case baz(String)
case bla(Int)
}
let events: [Foo] = [
.bar("David"),
.baz("Freddy"),
.bar("Bowie"),
.baz("Mercury"),
.bla(10)
]
Then to compactMap through associated values is as simple as
let davidBowie = events
.compactMap { $0[case: Foo.bar] }
.joined(separator: " ") //"David Bowie"
let freddyMercury = events
.compactMap { $0[case: Foo.baz] }
.joined(separator: " ") //"Freddy Mercury"
Today this is already possible with pattern matching, but readability is not the best
let davidBowie = events
.compactMap {
guard case let .bar(value) = $0 else { return nil }
return value
}
.joined(separator: " ") //"David Bowie"
let freddyMercury = events
.compactMap {
guard case let .baz(value) = $0 else { return nil }
return value
}
.joined(separator: " ") //"Freddy Mercury"
Mutability
CaseAccessible
has also an update function that will update the associated value of an enum case, if the pattern is matching
enum State: CaseAccessible {
case count(Int)
case error(String)
}
var currentState: State
currentState.update(value: 10, case: State.count)
// or by using subscripts
currentState[case: State.count] = 10
currentState[case: State.count, default: 0] += 1
Extensions
CaseAccessible
would open the way to new extensions for Collection, Observable (RxSwift), Signal (ReactiveSwift) and AnyPublisher (Combine) for filtering and mapping:
extension Collection where Element: CaseAccessible {
func filter<AssociatedValue>(case pattern: (AssociatedValue) -> Element) -> [Element] {
return filter {
$0[case: pattern] != nil
}
}
func compactMap<AssociatedValue>(case pattern: (AssociatedValue) -> Element) -> [AssociatedValue] {
return compactMap { $0[case: pattern] }
}
func exclude<AssociatedValue>(case pattern: (AssociatedValue) -> Element) -> [Element] {
return filter {
$0[case: pattern] == nil
}
}
}
enum ProductDetailEvent: CaseAccessible {
case loaded(Product)
...
}
let events: [ProductDetailEvent] = ...
let selectedEvent = events.filter(case: ProductDetailEvent.loaded) // [ProductDetailEvent]
let loadedProducts = events.compactMap(case: ProductDetailEvent.loaded) // [Product]
What does the swift community think?