The hidden (in plain sight) power of KeyPaths

KeyPaths - they’re everywhere nowadays. Let’s explore how we can design better APIs with them and have some fun along the way.


Lenses

Before we begin exploring KeyPaths in the wild, let us start with a little history overview and take a look at their ancestors, Lenses - which came to our world (as many other really useful things) from functional programming. I won’t give any Haskell (or other functional language) code for the examples, mainly because I can’t really read it, but Swift is more than enough to follow the concept. As you might already know, all the data in functional programming is basically immutable. We won’t dive into how that works and how to manage state (e.g., a bank account), but we need to set the constraint to our code examples in order to understand the very purpose of lenses. So let’s assume that we cannot create mutable properties, and all of them are lets. Suppose we’re writing a simple game engine:

enum Event { /*...*/ }

struct Vector {
	let x: Double
	let y: Double
	let z: Double
}

struct Player {
	let location: Vector
	let camera: Vector
}

func getNewState(player: Player, event: Event) -> Player {
	/*...*/
}

What we have here is an Event - something that triggers a state update (the user taps a button or whatever), a Vector that can represent both the location of our player and the direction of their camera, Player itself, and a function to handle events and update the state of the player. Right now, everything’s easy. Now let’s add some logic.

enum Event {
	case left
	case right
	/*...*/
}

func getNewState(player: Player, event: Event) -> Player {
	switch event {
		case .left:
			Player(
				location: Vector(
					x: player.location.x - 1,
					y: player.location.y,
					z: player.location.z
				),
				camera: player.camera
			)
		case .right:
			Player(
				location: Vector(
					x: player.location.x + 1,
					y: player.location.y,
					z: player.location.z
				),
				camera: player.camera
			)
	}
} 

It seems like too much code for a simple property change. And now the concept of lenses arrives. Lenses are just getters and setters; we can implement them ourselves:

struct Lens<Root, Value> {
	let /*@START_NOT_KEYWORD@*/get/*@END_NOT_KEYWORD@*/: (Root) -> Value
	let /*@START_NOT_KEYWORD@*/set/*@END_NOT_KEYWORD@*/: (Root, Value) -> Root
}

A lens in our case is a simple struct that has two closures: one for getting the Value out of the Root type and another for setting it (though because of immutability, it returns a new instance of Root). Now we can define a lens for getting and setting the x value of our Player’s location:

let locationXLens = Lens<Player, Double>(
	get: {
		$0.location.x
	},
	set: { player, value in
		Player(
			location: Vector(
				x: value,
				y: player.location.y,
				z: player.location.z
			),
			camera: player.camera
		)
	}
)

func getNewState(player: Player, event: Event) -> Player {
	switch event {
		case .left:
			locationXLens.set(player, locationXLens.get(player) - 1)
		case .right:
			locationXLens.set(player, locationXLens.get(player) + 1)
	}
} 

Now it’s better, but there’s still too much code for a simple property change. Some languages provide some mechanism for generating lenses for the type. Now, the coolest thing about lenses is that they are composable by nature. You can combine two lenses given their types are properly aligned:

extension Lens {
	func compose<NewValue>(
		with other: Lens<Value, NewValue>
	) -> Lens<Root, NewValue> {
		return .init(
			get: {
				other.get(self.get($0))
			},
			set: { root, value in
				self.set(root, other.set(self.get(root), value))
			}
		)
	}
}

Now we can create lenses and combine them any way we like:

let player = Player(/*...*/)

let locationLens = Lens<Player, Vector>(
	get: {
		$0.location
	},
	set: { player, location in
		Player(location: location, camera: player.camera)
	}
)

let cameraLens = Lens<Player, Vector>(
	get: {
		$0.camera
	},
	set: { player, camera in
		Player(location: player.location, camera: camera)
	}
)

let xLens = Lens<Vector, Double>(
	get: { $0.x },
	set: { vector, x in
		Vector(x: x, y: vector.y, z: vector.z)
	}
)

// Compose lenses to get location.x lens and camera.x lens

let cameraXLens = cameraLens.compose(with: xLens)
let locationXLens = locationLens.compose(with: xLens)

let cameraX = cameraXLens.get(player) // x value of the player's camera vector
let newPlayer = locationXLens.set(
	player,
	locationXLens.get(player) + 1
) // a player whose location's x value is incremented by 1

Now that we have a basic overview of lenses - let’s explore what the KeyPaths are.

KeyPaths

In Swift, KeyPaths are basically Lenses (but some of them are read-only) with a significantly better API for a language that supports mutation. They are also parametrized types with Root and Value parameters that allow us to read (or write) Values of the Root type:

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

Disregard PartialKeyPath for now, we’ll talk about the type hierarchy later. The easiest way to get a KeyPath instance is by using a key path literal:

let keyPath = \Player.location // KeyPath<Player, Vector>

When the compiler knows the Root type (either with explicit type or type inference) - you can omit it in the literal:

let keyPath: KeyPath<Player, _> = \.location

Every type in Swift has a special subscript that accepts a KeyPath and returns a value on the given path:

func getDouble<Root>(from root: Root, keyPath: KeyPath<Root, Double>) -> Double {
	root/*@START_HIGHLIGHT@*/[keyPath: keyPath]/*@END_HIGHLIGHT@*/
}

Likewise, WritableKeyPaths can be used to write properties:

func setDouble<Root>(
	to root: inout Root, 
	keyPath: WritableKeyPath<Root, Double>, 
	_ value: Double
) {
	root[keyPath: keyPath] = value
}

The type hierarchy

As you’ve seen already, KeyPaths are classes, and there is inheritance involved. The classes form the following type tree: AnyKeyPath -> PartialKeyPath<Root> -> KeyPath<Root, Value> -> WritableKeyPath<Root, Value> -> ReferenceWritableKeyPath<Root, Value> Here is the overview of what they are and what they are useful for:

  • AnyKeyPath is the root base class for all the *KeyPath types. Conforms to Hashable, so the other types are Hashable as well, which means they can be used as a Key in dictionaries. As the name implies - it is a type-erased version of KeyPath.
  • PartialKeyPath<Root> is another type-erased version of KeyPath, but this time only the Value type is erased. We can’t use it on any type other than Root, but we always get Any as the return value of the KeyPath-based subscript.
  • KeyPath<Root, Value> - is the most commonly used version of them all. It has both Root and Value types and it can be used to read the value from the Root.
  • WritableKeyPath<Root, Value> is, as the name implies, a version of KeyPath that allows us to write properties and not only read them.
  • ReferenceWritableKeyPath<Root, Value> is the version that allows mutating properties without mutating the object itself (reference semantics). Most of the time, these are KeyPaths to class member properties, but nonmutating set also counts for these KeyPaths.

The cool things about KeyPaths

1. KeyPath literal to function conversion

KeyPath literals can be converted to functions that have the following signature:

(Root) -> Value

This allows us to use APIs that accept closures passing KeyPaths instead:

let array = [0, 1, 2, 3] 

let descriptions = array.map(\.description) // ["0", "1", "2", "3"]

2. Composability

As in our example of lenses, KeyPaths are composable - you can combine them using the appending(path:) method that gives you a new KeyPath out of two:

let locationKeyPath = \Player.location
let xKeyPath = \Vector.x

let playerToLocationXKeyPath = locationKeyPath.appending(path: xKeyPath)
// same as \Player.location.x

3. Subscript access

KeyPaths can represent subscript access with all the parameters embedded in it. That means we can use KeyPaths to access a specific element in Array, for example. But there’s more: we can pass parameters to any subscript, including custom ones, the only restriction for the parameters is that the types of them must be Hashable.

let arrayFirst: KeyPath<[Int], Int?> = \.first
let arrayFirstUnwrapped: KeyPath<[Int], Int> = \.[0]

4. @dynamicMemberLookup

There is a special attribute in Swift that allows our types to define custom attributes and access them via so called “dot syntax”. All we need to do is implement a special subscript that accepts a parameter with a dynamicMember label. The parameter must be either an ExpressibleByStringLiteral or a KeyPath. That is the only constraint. The subscript can be generic, it can return any type we like, it can be get/set or read-only - it’s up to you to decide what it will be:

@dynamicMemberLookup
struct Wrapper<Wrapped> {
	var wrapped: Wrapped

	subscript<T>(dynamicMember keyPath: KeyPath<Wrapped, T>) -> T {
		wrapped[keyPath: keyPath]
	}
	
	subscript<T>(dynamicMember keyPath: WritableKeyPath<Wrapped, T>) -> T {
		get { wrapped[keyPath: keyPath] }
		set { wrapped[keyPath: keyPath] = newValue }
	}

	subscript<T>(
		dynamicMember keyPath: ReferenceWritableKeyPath<Wrapped, T>
	) -> T {
		get { wrapped[keyPath: keyPath] }
		nonmutating set { wrapped[keyPath: keyPath] = newValue }
	}
}

5. Type inference

The type inference works well with KeyPaths. When the compiler knows the type of the KeyPath, you can omit the Root in literals and, most importantly, change the types in the middle of it:

let intWidthKeyPath: KeyPath<Int, Int> = \.description.count

Even when the Value type is unknown, the compiler can understand the result:

let intWidthKeyPath: KeyPath<Int, _> = \.description.count

Now let’s have some fun with them, shall we?

The use cases

dynamicMemberLookup inheritance

The @dynamicMemberLookup attribute on protocols is (as expected) inherited by conforming types. So suppose we have a protocol ViewModel that all our ViewModels conform to. We can add some properties to all of them using extension on that protocol without adding properties individually to the types or exposing the origin of those properties:

public struct Dependencies {
	@TaskLocal
	static var current: Dependencies = .init(
		logger: .shared,
		analytics: .shared
	)

	public var logger: Logger
	public var analytics: Analytics
}

@dynamicMemberLookup
public protocol ViewModel: ObservableObject {
	subscript<T>(dynamicMember keyPath: KeyPath<Dependencies, T>) -> T { get }
}

public extension ViewModel {
	subscript<T>(dynamicMember keyPath: KeyPath<Dependencies, T>) -> T {
		Dependencies.current[keyPath: keyPath]
	}
}

And now all our ViewModels can use the global dependencies without having to store them individually:

final class VM: ViewModel {
	func buttonTapped() {
		self.logger.info("Tapped a button")
		self.analytics.send("Opening a screen")
	}
}

Design system colors (or other tokens) as KeyPaths

We can define our color tokens with KeyPaths:

struct ColorGuide {}

typealias ColorToken = KeyPath<ColorGuide, Color>

Which gives us the following advantages (assuming we remember all the cool things). First, we can group tokens:

struct ColorGuide {
	struct Backgrounds {
		let primary: Color = .white
		let secondary: Color = .gray
	}

	var backgrounds: Backgrounds { .init() }
}

This gives us more control over the correctness of API usage, we allow only tokens from a specific group to be a background color:

extension View {
	func background(_ token: ColorToken) -> some View {
		backgroundStyle(ColorGuide()[keyPath: token])
	}
}

This is cool, but enums can do it. But we can have more. Suppose we have to change the opacity of the selected token for certain components in our design system. But all our APIs accept only tokens. Properties can be computed, meaning they can be functions, but subscripts are real functions in the sense that they can accept parameters. We can add a subscript to Color and call it through KeyPath. This basically means that we can call any function through them. The only thing we need for it is to define the subscript:

extension Color {
	subscript(opacity value: Double) -> Color {
		self.opacity(value)
	}
}

let kp = \Color.black[opacity: 0.5]

Even more, because KeyPaths can change types in the middle, we can make those transformations look like a function call with fancy square brackets:

extension Color {
	struct Opacity {
		let color: Color

		subscript(value: Double) -> Color {
			color.opacity(value)
		}
	}
	var opacity: Opacity { .init(color: self) }
}

let kp: ColorToken = \.backgrounds.primary.opacity[0.5]

Which of these methods to use is up to you. Personally, I prefer the former for cases where I need to internally apply multiple modifiers in a quick succession:

func transform(userToken: ImageToken) -> ImageToken {
	userToken.appending(
		path: \.resizable[renderingMode: .template][antialiased: true]
	)
}

And the latter for the external APIs (especially the ones that accept more than one parameter) as it looks more like a function call and hence reads easier at the call site:

var body: some View {
	Icon(\.info.combined[with: .triangle].mode[.fill])
		.frame(width: 100, height: 100)
}

Bonus feature

Sometimes we might need to parametrize KeyPaths with types rather than plain values, but we can’t just pass types as parameters:

@Environment(\.[MyEnvKey.self])
var myEnvProperty
// Error: Subscript index of type 'MyEnvKey.Type' in a key path must be Hashable

That is expected. MyEnvKey.Type cannot be Hashable or conform to any protocol. But we can make a generic type that is Hashable and use its value to parametrize the subscript:

struct KeyWrapper<Wrapped: EnvironmentKey>: Hashable {}

extension EnvironmentValues {
	subscript<Wrapped: EnvironmentKey>(
		wrapper: KeyWrapper<Wrapped>
	) -> Wrapped.Value {
		get { self[Wrapped.self] }
		set { self[Wrapped.self] = newValue }
	}
}

struct MyView: View {
	@Environment(\.[KeyWrapper<MyEnvKey>()])
	var myEnvProperty // No error
}

Rarely are we going to need it, but sometimes it’s really useful. You’ll see the concrete use case for this in the next article.

Conclusion

KeyPaths are essential tools for crafting modern APIs. They simplify data manipulation tasks and seamlessly integrate with Swift. KeyPaths offer versatility and efficiency, allowing for cleaner code and improving overall development. With KeyPaths, you can create better APIs and streamline your coding process. See you in the next article.