[Discussion] Inclusion of CasePaths into the language

There's been some recent talk in the forum about quality of life improvement to enums, along with the inclusion of CasePaths into the language.

In particular, a couple of comments by the original co-author of CasePaths @stephencelis received a significant amount of support, which leads me to understand the community is craving a formal pitch.

After a brief DM exchange with @stephencelis, the main conclusion is that in order for a pitch for CasePaths to be taken seriously, we'd need a compiler engineer to lend a helping hand with the implementation. This way the pitch would be able to carry a significant punch as opposed to most other pitches without an implementation that end up in nothing.

If any heroic compiler engineers reading this are interested in helping out formalize a pitch, please reach out on this thread or DM.

And as a general question to the wider Swift community: are you interested in CasePaths officially making their way into the language?

35 Likes

I'm interested in having Casepath feature into keypath in order to write a keypath that can go through enum and struct.

3 Likes

Just an alternative spelling:

Instead of /Type.case how about a double backslash \\Type.case.

The normal slash can be to easily result into a comment or clash with other / operators.

The library only uses /Enum.case as a way of imitation. If there were first class support for case paths (enum key paths) in the language, I imagine we'd use \Enum.case.

To disambiguate between computed properties: \Enum.case as CasePath<Enum, Case>
To disambiguate from overloaded case names: \Enum.case(arg1:) vs. \Enum.case(arg2:)

14 Likes

I don't mind first class key-path syntax.

This seems sensible to me. The number of times you'll need to disambiguate is small enough to afford reusing the already-familiar \ operator IMO.

3 Likes

While we're at it, one other thought is the mirroring of [keyPath:] subscripts with [casePath:].

After reading this thread, it's clear to me there are two main differences between subscripting key paths vs case paths.

The first difference is that key paths refer to a value that's accessible within a specific instance, whilst a case path can either be used to access an instance OR embed (create) a new enum instance from itself. So a mechanism for this shall be offered too by the language β€” either as the established embed function or maybe as something fancier with help from the compiler.

The second, more troubling difference, is that a [casePath:] subscript's getter must always return an optional value in case the enum case doesn't match the case path (mouthful), BUT the setter not necessarily needing to receive an optional type since the subscripted instance itself can be a non-optional. My thought on this is that we either have to introduce an exception for case path subscripts to allow for discrepancies in get and set value types at the compiler level, or we have to learn to live with this ambiguous mental model.

I'd appreciate your input on this @stephencelis.

2 Likes

I'll happily chime in to reiterate my comment from the aforementioned thread and state that I would love to see CasePaths introduced as a language feature (as well as Enum properties but I suppose that's maybe for another thread).

The discrepancy in ergonomics between Structs and Enums is probably the area where I'd most frequently benefit from quality of life improvements, and in my experience a non-negligible source of friction to the wider adoption of safer, more unambiguous patterns/architectures in Swift codebases.

4 Likes

Subscripts are not currently capable of doing what a case path (or even a writable optional-chained path) needs to do because the type returned from the getter is different from the type sent to the setter:

  • The getter is optional
  • The setter is non-optional

This behavior would either need to be special cased by the compiler, or subscript/property syntax would need to change to support this. Pseudosyntax:

subscript<Value>(
  casePath: CasePath<Self, Value>
) -> Value? {
  get { … }
  nonoptional set { … }
}

I’d rather not get into design details, though, without a means of implementing. But if a person familiar with the compiler is interested in working on an implementation, I’d be more than happy to help with the design.

6 Likes

I'd be very interested in CasePaths making their way into Swift. The imbalance of ergonomics between enum and struct always bothered me and I feel this would go a long way in making the language more complete and expressive.

7 Likes

I don't think anybody would claim that enums are ergonomic. They're really powerful, for sure, but also incredibly awkward.

I'm not sure that 'case paths' are the right answer. They're a clever workaround given they need to work in the language we have, but if we're going to change the language to make enums more usable, there's a wide design space we should explore.

For example, we already have if case syntax (which I hate, but unless I'm missing something it's designed to do exactly this). What if we expanded on that syntax?

enum MyEnum {
  case foo(Int)
  case bar
}

let anEnum: MyEnum = ...

// Works today:
if case .foo(let payload) = anEnum {
  // (payload: Int)
}

guard case .foo(let payload) = anEnum else { ... }
// (payload: Int)

// One idea...
let case .foo(payload)? = anEnum
// (payload: Int?)

Again, I very much dislike if case syntax -- but, I'd prefer something which fits existing language constructs over introducing a whole new concept.

(The reason I dislike if case so much is that it looks like an assignment but it's part of a if condition. I get why it's that way, but it just feels wrong. An actual assignment to a local variable wouldn't have that problem)

3 Likes

One issue with this syntax is that you can't chain multiples access while keyPaths and CasePaths can. I see CasePaths not as whole new concept and more a generalisation of keyPaths. Using the same syntax could make them interchangeable like .someStruct.someCase.someProperty.

4 Likes

And beyond property access, it also enables other meaningful abstractions like PartialCasePath and a theoretical CasePathIterable protocol.

This ties in as a solution to the original Enum Quality of Life Improvements problem:

enum Navigation: CasePathIterable, Hashable {
    case firstScreen(FirstScreenData)
    case secondScreen(SecondScreenData)

    // Compiler generated
    static var allCasePaths: [PartialCasePath<Navigation>]
}

let navigationStep = Navigation.firstScreen([...])

// representing a case label
let unassociatedNavigationStep = \Navigation.secondScreen as PartialCasePath

// matching cases by label only
let isFirstScreen = navigationStep ~= \.firstScreen // true
let isSecondScreen = unassociatedNavigationStep == \.secondScreen // true

// counting total enum cases
let totalNavigationSteps = Navigation.allCasePaths.count // currently impossible to determine without hardcoding or dropping associated values
3 Likes

Just coming here to say that YES 100%.

But is important that the proposed solution not only adds CasePaths but that it lets us mix them with keypath nicely, something that is not yet public on the library afaik.

I always thought that once Stephen and Brandon figured a nice way of doing that they would propose it to SE themselves :yum: I’m so glad this is happening. Is a big missing feature of the language.

I agree with exploring the entire design space, If there is a better solution out there it would be nice to find it. But CasePaths are NOT a workaround of anything, in the same way that KeyPaths aren't.

I don't think anybody would think that about KeyPaths. They may not be a necessary feature in a language, but we agree that once they were added to Swift an entire new space of API design opened. CasePaths are just filling the whole for Enums.

As stated by others, I don't think we're closing the door to other enum improvements by introducing case paths. They are just unrelated things.

I would also love to see a way to match on cases of an enum ignoring the associated values of it. Even tho CasePath help with that I agree that we should add something to the language to let us do that.

But just remember that CasePaths are not JUST for that.

I don't want to be pessimistic, but it's not clear that it'll happen until we find someone that's able to provide an implementation to go along with the pitch. That's one of the motivations for my original post.

TL;DR

Key paths have a lot of room to grow. I fear that coming up with different types, like CasePath, for what can ultimately be a key path, will only fragment the language. Instead, we could allow no-payload enum cases and static properties in key paths, as a straightforward extension. Then, if all goes well, we could explore how functions could fit into the key path model, to resolve key paths' inconsistencies.


Perhaps I've missed something, but integration into the existing key path feature seems more consistent with the language:

// Enums cannot currently refer to cases
let colorConstructor: KeyPath<Color.Type, Color> = \.red

Color.self[keyPath: colorConstructor]

If we allow cases, we could even go a step further with static properties. Key paths to static properties will allow types to evolve from enums to structs without a source breakage. It would also be consistent with convenience static properties β€” e.g. static let 'default' = Self.black.

Finally, we could allow functions key paths, significantly improving @dynamicMemberLookup proxy types:

@dynamicMemberLookup
struct KeyPathIdentifiable<Root, Value: Hashable>: Identifiable

extension Identifiable {
  static func identifiable(
    _ root: Root, by keyPath: KeyPath<Root, Value>
  ) -> Identifying<Root, Value> where Self == Identifying<Root, Value>
}

// === Use β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”β€”

struct User {
  let id = UUID()
  let name: String

  func rename(to newName: String)
}

let user: some Identifiable = .identifiable(
  User(name: "Sam"), 
  by: \.id
)

// ❌ Key paths cannot refer to functions
idUser.rename(to: "Liz")

Arguably, key paths to functions are a bit unrelated and pose problems on their own β€” i.e. "Can they be applied?", "How will the resulting key path be hashable?", "How can argument labels be carried?"

4 Likes

The point of CasePaths is that they in fact cannot just be KeyPaths.

CasePaths are an abstraction over an enum case, whereas KeyPaths are an abstraction over properties of a class, struct or even enum. Cases can also be plain or have associated types, which introduces a whole other range of needs for the abstraction as well. Trying to shoehorn all that into KeyPath for the sake of consistency would violate SRP and, down the road, bring a bunch of headaches to the Swift Core Team.

I'd say the CasePaths library as it is has been battle-tested enough to grant its design as an abstraction of its own.

2 Likes

A somewhat related idea that I’ve been sitting on for a couple of years is to allow case statements to be captured as closures, in the same way keypaths can.

For example, consider:

class Dispatcher<Event> {
    func dispatch(event: Event) { ... }
    func addHandler<Payload>(
        `for` matcher: (Event) -> Payload?,
        handler: (Payload) -> Void
    ) { ... }
}

enum MyEvent {
    case foo(count: Int)
    case bar
}

let dispacher = Dispatcher<MyEvent>()
dispatcher.addHandler(for: case .foo(let count)) { count in
    ...
}

// desugars to:
dispatcher.addHandler(
    for: { (e: MyEvent) -> Int? in
        if case .foo(let count) = e {
            return count
        } else { 
            return nil
        }
    }
    handler: { count in
        ...
    }
)

(I actually wrote a draft pitch about this two years ago, but never got around to publishing it because I wanted to explore avenues where the case could be captured in a way that allowed using the discriminator of the enum as a lookup key.)

There’s been talk about adding support for custom destructuring matchers; presumably the shape of such a matcher would be the same as the shape of a captured case closure, creating a pleasing symmetry.

1 Like

That's one perfect example of what CasePaths are here to solve:

class Dispatcher<Event> {
    func dispatch(event: Event) { ... }
    func addHandler<Payload>(
        `for` matcher: CasePath<Event, Payload>,
        handler: (Payload) -> Void
    ) { ... }
}

enum MyEvent {
    case foo(count: Int)
    case bar
}

let dispacher = Dispatcher<MyEvent>()
dispatcher.addHandler(for: \.foo) { count in
    ...
}

No need for further syntactic sugar.

1 Like