Enum Case KeyPaths

Optional is an enum, and if we think of chaining ? onto an optional value as shorthand for .some?, then the syntax plays nicely with other enums.

3 Likes

Yes technically it's an enum, but I would say that's an implementation detail. I don't think most developers ever think of optionals that way. It seems much more natural to think along the lines of "this value might not be here, so I have to put a ? before calling a property or method on it.

Actually I'm not sure I even understand the argument. Sure, when chaining we could write

view.superview.some?.backgroundColor

which would have

view.superview?.backgroundColor

as retroactively motivated sugar. But that still doesn't seem to motivate standalone

view.superview.some?

since we don't use

view.superview?

It's very late here so I might be confused about what the motivation actually was.

Another thing, wouldn't this also mean we have to introduce a whole new concept? Are there any existing situations where a statement is invalid without an operator at the end?

asset.remote

would have to be a syntax error, right?

1 Like

Trailing ? and .some? doesn't exist today, but we could think of them as an expansion of optional chaining syntax. .some? would be a kind of "identity" operation for optionals in a way similar to how .self is an "identity" operation for values.

So while optional-chaining is limited to chaining into another property today, and has optional-reading, non-optional-writing semantics:

view.superview?.backgroundColor        // Optional<Color>
view.superview?.backgroundColor = .red // βœ…
view.superview?.backgroundColor = nil  // πŸ›‘

But we could expand ? syntax to generally mean "open an enum payload," and when attached to an optional, could expand to .some? automatically.

This allows us to use the same optional-chaining semantics to distinguish how we access and modify enum values (and by extension, optional values):

view.superview // Optional<UIView>
view.superview = label // βœ…
view.superview = nil   // βœ…

view.superview.some? // Optional<UIView>
view.superview.some? = label // βœ…
view.superview.some? = nil   // πŸ›‘

// or, shorthand:
view.superview? // Optional<UIView>
view.superview? = label // βœ…
view.superview? = nil   // πŸ›‘

The thing I like about using ? is we're not introducing a whole new concept, but instead are extending an existing syntax in a way that will hopefully feel natural.

asset.remote is invalid today, and in the syntax I'm theorizing it would require a trailing ? to be valid.

5 Likes

So if I’m understanding correctly, the trailing ? would enforce optional reading, non-optional writing of values, which would apply the same way to KeyPaths (e.g. \UIView.superview?).

If that’s so, then am I correct in assuming that the base class for case key paths would be a subclass that offers optional reading, non optional writing semantics? As in, would that be the only key path type available capable of representing enum cases?

If so, does that pose concern for supporting backwards compatibility with older runtimes?

The composition of enum case access and property access would be the key path type that @Joe_Groff referred to here: Enum Case KeyPaths - #4 by Joe_Groff

And this key path type would be between KeyPath and CasePath:

class KeyPath<Root, Value>: PartialKeyPath<Root> { … }

class OptionalWritableKeyPath<Root, Value>: KeyPath<Root, Value?> { … }

class CasePath<Root, Value>: OptionalWritableKeyPath<Root, Value> { … }

I think Joe mentioned that case paths and optional paths could back-deploy as KeyPath<Root, Value?>.

That makes sense.

So in summary:

\Asset.remote?.url // KeyPath<Asset, URL?>
\Asset.remote? // OptionalWritableKeyPath<Asset, Remote>
\Asset.remote // CasePath<Asset, Remote>
asset.remote?.url = nil // βœ… what does it do? No-op?
asset.remote? = nil ⛔️
\Asset.remote.embed(Remote(url)) βœ… equivalent to Asset.remote(Remote(url))

Is this all correct? I’m a bit confused about how the chaining with payload properties would work.

I think it'd be more like:

\Asset.remote?.url // OptionalWritableKeyPath<Asset, URL>
\Asset.remote?     // CasePath<Asset, Remote>
\Asset.remote      // πŸ›‘ invalid unless `Asset` has computed `var remote`
asset.remote?.url = nil // πŸ›‘ invalid, just like optional chaining today
asset.remote? = nil     // πŸ›‘ invalid
(\Asset.remote?).embed(Remote()) // βœ… equivalent to `Asset.remote(Remote())`
2 Likes

I see now. Makes sense! I’m a bit bummed that the parenthesis are still needed for embedding but technically it makes sense.

One last question, if we had a longer chain like \Asset.remote?.urlComponents.query… where query is a String? itself, would the type be OptionalWritableKeyPath<Asset, String?>?. If so, would the read value be String??? Should the compiler be flattening the entire thing to WritableKeyPath<Asset, String?> in that case? Or does it make sense to maintain the type with a double optional?

I believe all optional-chaining semantics would be preserved, so it should behave the same as the same optional chain on an actual value. If writable, it'd be an OptionalWritableKeyPath<Asset, String?>, if read-only it'd still flatten to KeyPath<Asset, String?>.

2 Likes

Wouldn't this actually prevent Asset from having a computed, optional .remote property? I mean what would asset.remote?.absoluteString refer to then, the property or the case?

Not necessarily. There could be inference and disambiguation rules.

// Disambiguation:
(asset.remote)?.absoluteString    // disambiguate optional `var`
asset.remote.some?.absoluteString // disambiguate optional `var`
(asset.remote?)?.absoluteString   // disambiguate `case`

// Default inference:
asset.remote?.absoluteString
// * Prefer `var remote` for source compatibility,
//   fall back to `case remote` if it exists, or fail.
// * Prefer `case remote`,
//   require `.some?` or parentheses for disambiguation.
// ...or some other rule

There's also always the option to disallow var remote where case remote exists. It could be source-incompatible, but may be deemed OK, especially if most overlap today is for the exact same functionality, or it could be introduced with Swift 6, which would allow for more source compatibility breakage.

4 Likes

In any case, wouldn't it make sense to have people opt in to these case paths? Useful as they are, most of the time you don't actually need them.

AFAIK, Swift usually tries to stay apart of this kind of configurable behavior, as this leads in one way or another to the creation of different dialects. The only exceptions I'm aware of are temporary, in order to preserve backward compatibility (like it's currently the case for the bare regex syntax).

3 Likes

I just meant a pseudo-protocol along the lines of CaseIterable.

I don't think CaseIterable is a pseudo-protocol. While Swift provides automatic conformances where appropriate, e.g. enums with no associate values, that seems more similar to how Equatable, Hashable, Codable, RawRepresentable, etc., conformances are opt-in and can often be trivially synthesized. You can also write a custom conformance to CaseIterable for any data type.

Meanwhile, I'm not sure I see how this transfers to key path functionality. There's no opt-in/out mechanism for property key paths, and I don't see a motivation to not provide the same functionality to all enum cases when it's possible to do so. Can you explain your reasoning?

Whatever CaseIterable is I think the situation here is pretty similar. You conform to a protocol and get synthesized properties. For CasePaths we synthesize several sort-of-properties, one for each case, and the names are also more likely to clash with what you already have.

So if we think it makes sense to have to manually ask for .allCases, why shouldn't it be the same for CasePaths? Just like with CaseIterable, CasePaths can be very useful, but it's also true that in 95% of enums it's not needed, so it does seem a bit frivolous to always add it. Unless it's not even considered as synthesized properties I guess.

Consider the hypothetical scenario where a CasePath<Enum, Value> and PartialCasePath<Enum>
had been introduced to the language, and SE-0914 came along with both CaseIterable and CasePathIterable.

If we look at it from this perspective, we can find a pretty convincing argument for case paths to be first class citizens in the language, as they're in fact the equivalent of supercharged enum discriminators!

Synthesized enum discriminators are one of the most requested features for enums, second only to extracting payloads from enum cases more easily. Both of these problems are solved in the same way: by introducing a way to refer to the discriminator of an enum case and operate with it, aka being able to refer to a "Case Path".

In the case of CasePathIterable, you'd get something like:

enum Foo: CasePathIterable {
    case a, b, c

    // synthesized
    static var allCasePaths: [CasePath<Bar, Void>] // contains \Foo.a?, \Foo.b?, and \Foo.c?, which actually act as discriminators!

    // equivalent to:
    enum Discriminator: CaseIterable {
        case a, b, c
    }
    static var allDiscriminators: [Discriminator] // contains Discriminator.a, Discriminator.b, Discriminator.c
}

enum Bar: CasePathIterable {
    case a(String)
    case b(Int)
    case c(URL?)

    // synthesized
    static var allCasePaths: [PartialCasePath<Bar>] // contains \Bar.a?, \Bar.b?, and \Bar.c?, which actually act as discriminators!

    // equivalent to:
    enum Discriminator: CaseIterable {
        case a, b, c
    }
    static var allDiscriminators: [Discriminator] // contains Discriminator.a, Discriminator.b, Discriminator.c
}

CasePath and PartialCasePath could even be made exhaustively switchable at compiler level since they refer to a specific enum type, so there's always a finite amount of known paths for a given Root type.

As you can see, these are not part of an opt-in protocol, these are first class citizens leveraged by the better opt-in protocol: CasePathIterable.

2 Likes

Interesting, I didn't realise CasePaths could be used as discriminators, how does that work?

Examples:

let bar = Bar.a("hello")
let barDiscriminator = PartialCasePath(bar) // PartialCasePath<Bar>
switch barDiscriminator {
case \.a?: // ...
case \.b?: // ...
case \.c?: // exhaustive!
}
enum PaymentMethod: CasePathIterable {
    case creditCard(CreditCardBrand)
    case paypal
}

extension PartialCasePath where Root == PaymentMethod {
    var title: String {
        switch self {
        case \.creditCard?: return "Credit Card"
        case \.paypal?: return "PayPal"
        }
    }
}

let allPaymentMethodsTitles = PaymentMethod.allCasePaths.map(\.title) // ["Credit Card", "PayPal"]

func setPaymentMethodKind(_ paymentMethodKind: PartialCasePath<PaymentMethod>) {
    self.paymentMethodKind = paymentMethodKind
    self.label.text = paymentMethodKind.title
}

setPaymentMethodKind(\.creditCard?)

// even...

func setCreditCardBrand(_ creditCardBrand: CreditCardBrand) {
    if let creditCard = self.paymentMethodKind as? CasePath<PaymentMethod, CreditCardBrand> {
        self.paymentMethod = creditCard.embed(creditCardBrand)
    }
    // or:
    if self.paymentMethodKind == \.creditCard? {
        self.paymentMethod = .creditCard(creditCardBrand)
    }
}

setPaymentMethodKind(\.paypal?)
setCreditCardBrand(.masterCard) // self.paymentMethod is still .paypal

setPaymentMethodKind(\.creditCard?)
setCreditCardBrand(.masterCard) // self.paymentMethod is now .creditCard(.masterCard)
1 Like

I think we'll eventually want to support inout bindings in pattern matches and conditionals, which would allow for things like:

// Replace β˜ƒ with your favorite case projection syntax
if inout payload = enumValue.β˜ƒcaseName {
  modify(&payload)
}

switch enumValue {
case .caseName(inout payload):
  modify(&payload)
case .otherCaseName(inout otherPayload):
  modify(&payload)  
}

and so if we have optionally-writable key paths, it'd be cool to have if inout payload = value[keyPath: kp] work as well.

16 Likes