Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Case Paths + Key Paths = Optional Paths #190

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft

Conversation

stephencelis
Copy link
Member

This PR adds some basic support for case/key path composition via "optional" paths.

Often there are case path APIs that don't need the full power of a case path. You might need to extract an optional value, or update an existing optional value, but you don't need to embed that value into a whole new root. This is the behavior of optional-chaining in Swift, which is sadly unsupported when it comes to key paths: an optional-chained key path loses writability.

This PR changes that by expanding the functionality of case key paths so that they gracefully degrade to an "optional" key path.

For example:

@CasePathable
enum Destination {
  case login(Login)
}

struct Login {
  var username = ""
  var password = ""
}

let loginKeyPath: CaseKeyPath<Destination, Login> = \.login

loginKeyPath as OptionalKeyPath  // ✅

let loginPasswordKeyPath: OptionalKeyPath<Destination, String> = \.login.username  // ✅

Some details and experimentation to figure out before merging, so opening as a draft for now.

@stephencelis stephencelis requested a review from mbrandonw August 7, 2024 21:40
}
)
}
set {}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type system now tracks case key paths as writable key paths, and optional paths as plain ole key paths.

CaseKeyPath    <R, V> = WritableKeyPath<Case<R>, Case<V>>
OptionalKeyPath<R, V> =         KeyPath<Case<R>, Case<V>>

This empty setter is never really invoked, it's just responsible for ensuring case key paths compose together without losing embed functionality.

Comment on lines 139 to +141
public func extract(from root: Any) -> Value? {
self._extract(root)
self._get(root)
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a bit of a mismatch in this PR where sometimes we say "extract" and sometimes we say "get" (mostly just in the initializer for symmetry with "set"). Not sure if we want to consider something else.

Comment on lines +324 to +326
public func extract<R, V>(from root: R) -> V? where Root == Case<R>, Value == Case<V> {
Case(self).extract(from: root)
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without a CasePathable requirement to tie a [case:] subscript to, we need a way to utilize this functionality more generally.

Case(self).extract(from: root)
}

public func set<R, V>(into root: inout R, _ value: V) where Root == Case<R>, Value == Case<V> {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New functionality. Worth bike-shedding the API or good as is?


public func extract<R>(from root: R) -> Any? where Self == PartialOptionalKeyPath<R> {
(Case<R>()[keyPath: self] as? any _AnyCase)?._extract(from: root)
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Add a set(into:_:) API for partial paths?

@Sendable func absurd<T>(_: Never) -> T {}
return Case<Never>(embed: absurd, extract: { (_: Value) in nil })
}
set {}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These sets are to preserve case key path functionality. Luckily the weirdness is all hidden away in the "internal" Case type.

@stephencelis
Copy link
Member Author

A few other things come to mind:

  1. Is it weird to call the internal thing Case still now that it can refer to non-enum values?
  2. While \Enum.Cases.case made sense for qualifying case key paths, it makes less sense for optional paths: \Struct.Cases.property
  3. It's possible to create an "optional" path from any non-optional key path. This is required for composition to work (\.self paths, as well as \.nonOptionalProperty.optionalProperty). It may feel weird to allow an API that takes optional paths to take a non-optional one, but it also seems the most flexible and we've even had an example in isowords where we wanted to generically ifLet to an optional path in one case and a non-optional path in another.

@JimRoepcke
Copy link

@stephencelis I was using the old OptionalPath code in a project that was on TCA 1.10.4 and swift-case-paths 1.3.0. Trying to migrate it to TCA 1.13.0, and had some compilation errors due to deprecations with case paths.

Just stumbled across this PR, what great timing! Just curious if you know what the timing might be for getting this merged and released?

@stephencelis
Copy link
Member Author

Hi @JimRoepcke! Theoretically this branch is ready to go, but we haven't really prioritized it. In most of our projects we've remodeled our domains in ways that no longer require optional paths, and so the main thing needed for this branch to land is some beta testers, which is where you could come in 😄

Want to point your project at this branch and take things for a spin to see if it unblocks you? We're eager for feedback from users with real world requirements!

@JimRoepcke
Copy link

JimRoepcke commented Aug 19, 2024

@stephencelis I pointed my code at this branch. Working through issues in my code, but I'm also seeing TCA (1.13.0) itself is not building?

image

When I cmd-click on id(state:action:) it takes me here:

image

@fruitcoder
Copy link

Hi @stephencelis, will this also fix an issue I had when trying to model my domain by wrapping my enum reducer in another enum before putting it in State? The code can be simplified to this

@CasePathable
enum LoadingState: Equatable {
  case loading
  case loaded(ContentReducer.State)
}

@Reducer(state: .equatable)
enum ContentReducer {
  case child1(Child1Reducer.State)
}

@Reducer
struct Child1Reducer {
  typealias State = Never
  typealias Action = Never
  typealias Body = EmptyReducer
}

@Reducer
struct Child2Reducer {
  typealias State = Never
  typealias Action = Never
  typealias Body = EmptyReducer
}

@Reducer
struct Feature {
  @ObservableState
  struct State: Equatable {
    var content: LoadingState = .loading
  }

  enum Action {
    case content(ContentReducer.Action)
  }

  public var body: some ReducerOf<Self> {
    EmptyReducer()
      .ifLet(\.content.loaded, action: \.content) {
        ContentReducer.body
      }
  }
}

This gives me the following error:
image

@stephencelis
Copy link
Member Author

@fruitcoder Technically it should, though TCA would need to update all its helpers to work with optional paths rather than key paths and case paths.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants