@Tylerwbrown A lot of people have given you reasons for why you shouldn't do this, and I 100% agree with them, but there is a way to accomplish what you want with some tricks.
Before demonstrating these tricks I want to make it abundantly clear that I do not think any of this is actually necessary, and key paths should be used for this. I even gave a popular talk on lenses in Swift 8 years ago (almost to the day!), but that pre-dated native Swift key paths by 2 years and once native key paths were available I converted all code to use them.
Key paths are far more ergonomic, more powerful, and adopting a "lets everywhere, vars nowhere" mantra is only working against the language and will lead to pain down the road. Value types give you all the safety and locality you need, even with vars, and even adopting the mantra does not change the fact that all other code you interact with happily uses vars (such as the standard library).
But with that caveat aside, you can adopt the approach we took to develop our CasePaths library to accomplish what you want. The crux of the technique is that we want to leverage native key path syntax for something that is not natively supported by Swift, such as key paths for cases of an enum (or "prisms" as they are known in functional languages). We of course really wish Swift would have native support for enum key paths, but until then we were able to brute force it in with macros.
Here is how it goes, slightly adapted for "lenses" instead of "prisms". The trick is to house the lenses inside the type that you want to derive lenses for, similar to what your @makeLenses
does, but instead of static var
s it is a new nested type with var
s. Here is a small amount of library code you have to maintain to abstract over this concept and make it composable:
protocol Lensable {
associatedtype AllLenses
static var allLenses: AllLenses { get }
}
typealias Lens<Root: Lensable, Value> = KeyPath<Root.AllLenses, AnyLens<Root, Value>>
@dynamicMemberLookup
struct AnyLens<Root, Value> {
let get: (Root) -> Value
let set: (Root, Value) -> Root
subscript<AppendedValue>(
dynamicMember keyPath: KeyPath<Value.AllLenses, AnyLens<Value, AppendedValue>>
)
-> AnyLens<Root, AppendedValue>
where Value: Lensable
{
AnyLens<Root, AppendedValue>(
get: { Value.allLenses[keyPath: keyPath].get(self.get($0)) },
set: { self.set($0, Value.allLenses[keyPath: keyPath].set(self.get($0), $1)) }
)
}
}
With that you can define the AllLenses
inner type inside your models holding onto the lenses, and you can make the models conform to Lensable
(easily accomplished with a @Lensable
macro):
struct User {
let id: Int
let address: Address
// Macro generates this
static let allLenses = AllLenses()
struct AllLenses {
let id = AnyLens(get: \.id, set: { User(id: $1, address: $0.address) })
let address = AnyLens(get: \.address, set: { User(id: $0.id, address: $1) })
}
}
// Macro generates this
extension User: Lensable {}
struct Address {
let city: String
let state: String
// Macro generates this
static let allLenses = AllLenses()
struct AllLenses {
let city = AnyLens(get: \.city, set: { Address(city: $1, state: $0.state) })
let state = AnyLens(get: \.state, set: { Address(city: $0.city, state: $1) })
}
}
// Macro generates this
extension Address: Lensable {}
And with that done you immediately get to use native Swift key path syntax to derive lenses:
let userAddressLens: Lens<User, Address> = \User.AllLenses.address
And then key path composition corresponds to lens composition:
let userCityLens: Lens<User, String> = \User.AllLenses.address.city
That's right. userCityLens
is a proper lens focusing from User
all the way down to the String
of the city, albeit expressed in key path syntax. All model fields are still let
s, yet you can make a mutation to a user's city:
let user = User(id: 42, address: Address(city: "Brooklyn", state: "NY"))
// Relocate user from Brooklyn to Queens
let relocatedUser = User.allLenses[keyPath: userCityLens].set(user, "Queens")
This works even though user
and all of its fields are completely immutable. It's a little verbose, but you can define your .~
operator in terms of Lens
so that you can do things like this:
let user = User(id: 42, address: Address(city: "Brooklyn", state: "NY"))
// Relocate user from Brooklyn to Queens
let relocatedUser = user
|> userCityLens .~ "Queens"
…but I will leave the implementation of .~
as an exercise for the reader.
So, this is all possible, but I will iterate again that I personally do not think this buys you anything over Swift's value semantics and key paths, and in fact only hurts the quality of your code and the ability to take full advantage of what the Swift language has to offer.
But, the main reason I wanted to post this solution is just to make it more widely known how one can leverage Swift key path syntax for things that are not exactly key paths, and how key path composition can be naturally transported to other kinds of composition[1]. It is incredibly handy, and it's what allowed us to make "case key paths" seem as native as key paths, though we still do hope for a day of truly native case paths.