47 Degrees joins forces with Xebia read more

Swift KeyPaths under a different optic

Swift KeyPaths under a different optic

In a previous post, we presented an introduction to optics, and we covered how optics are useful for dealing with deeply nested immutable data structures, and most modern languages have libraries to work with them.

Their implementations are quite similar. As an example, let’s consider a lens. A lens is a couple of functions to get and set a value in a data structure, assuming it will always be present. In Swift, this can be implemented as:

struct Lens<Source, Focus> {
  let get: (Source) -> Focus
  let set: (Source, Focus) -> Source
}

In fact, this is how lenses are implemented both in Bow and Bow Lite. Then, assuming we have the following data model:

struct Author {
  let name: String
  let age: Int
}

struct Article {
  let title: String
  let author: Author
}

We can create a lens to target the author or an article as:

let authorLens = Lens<Article, Author>(
  get: { article in article.author },
  set: { article, newAuthor in Article(title: article.title, author: newAuthor) }
)

And using lenses is as easy as:

authorLens.get(article) // Obtains the author of the article
authorLens.set(article, anotherAuthor) // Returns a new article, replacing the original author

It may seem like this doesn’t add much; it is a lot of boilerplate just to get/set a value from a data structure, and our language already does that with better syntax. However, optics really shine (and are worth it) when we compose them with others. Consider, for instance, that we write another lens to access the name of an author:

let nameLens = Lens<Author, String>(
  get: { author in author.name },
  set: { author, newName in Author(name: newName, age: author.age) }
)

We can compose these two optics to get a more powerful one to get or modify the name of the author of an article. Using Bow Optics, this is easily done with the + operator:

let composedLens: Lens<Article, String> = authorLens + nameLens

The main disadvantage of this way of working with optics is writing the lenses manually. In many other libraries, optics can be generated using some sort of metaprogramming. In Swift, it turns out that the compiler gives us everything we need!

KeyPaths

If we pay closer attention to the previous definition of a lens, we see that there is already a construction in Swift that lets us get or set a property of a data structure in a general manner. We can do this by using KeyPaths (or WritableKeyPaths, in particular). We can revisit the previous example, with a small modification:

struct Author {
  var name: String
  var age: Int
}

struct Article {
  var title: String
  var author: Author
}

Notice the definition of these structures is the same, but their properties are now declared as var, instead of let. This is because we need to obtain a WritableKeyPath for those fields, and having fields declared as let prevents us from doing it.

Then, we can use the compiler generated key paths to get and set the author of an article:

let path: WritableKeyPath<Article, Author> = \Article.author
article[keyPath: path] // Get part of the optic
article[keyPath: path] = anotherAuthor // Set part of the optic

Using the operator \, we can navigate through the fields of a structure to point to our focus of interest, and then use the subscript operator to get and set it. Where this really shines is when we compose it to access multiple levels within our data structure. For instance, suppose we want to access the name of the author of an article. We would have two WritableKeyPath, namely \Article.author and Author.name. In Bow Optics, we could use operator +; here, we just use the . operator to keep navigating through the hierarchy of fields:

article[keyPath: \.author.name] // Gets the name of the author
article[keyPath: \.author.name] = newName // Sets a new name for the author of the article

Applications of key paths as optics

By now, you should have used key paths in a multitude of situations, even if you didn’t know they were lenses in disguise! Here is a list of use cases:

  • Key paths can be used as functions (since Swift 5.2), for instance when mapping over containers. In fact, what’s happening there under the hood is that we are using the getter function of the lens.
let articles: [Article] = [ /* ... */ ]
let authors = articles.map(\.author) // Use a key path as a function
  • They are frequently used in Combine to do data binding. We can perform multiple transformations to a Publisher, and finally use assign(to:on:) to perform, for instance, a user interface update.
articlePublisher
  .map { "Title: \($0.title)"}
  .assign(to: \.text, on: label) // Use a key path to assign on a specific property
articleReducer.pullback(
  state: \.articles, // Use a key path to focus on part of the state
  action: .self,
  environment: id)

These are just a few of multiple use cases you can find for key paths. In Bow and Bow Lite, we use them to obtain an automatic implementation for our lenses and other optics.

Conclusions

By now, you might be wondering why we still need an optics module like the one we have in Bow. There are multiple reasons:

  • We mentioned that optics are good for working with immutable data structures, but in order to obtain our writable key paths, we had to make our data structures mutable. This is a tradeoff that we have to make if we want to use this Swift feature, with the implications that having mutable data structures have in our code.
  • Key paths, or lenses, are just one type of optic, but there is much more to it! The optics module in Bow provides different ones, from prisms to traversals, which are not considered in Swift so far. Bow Optics also lets you compose different types of optics with each other.
  • In a more advanced way, lenses (and other optics) can be polymorphic; this means we can modify the type of the source and focus with an optic. Polymorphic optics are also implemented in Bow Optics.

Still, having key paths implemented as part of the language gives us an excellent feature to practice Functional Programming. This is just another example of how features from functional languages are permeating into mainstream languages and bring great benefits with their usage. Which feature will be the next one we import into Swift?

Please check out the following Bow resources. Comments and questions are welcome and encouraged!

Bow, and its ecosystem, is proudly sponsored by 47 Degrees, a Functional Programming consultancy with a focus on the Scala, Kotlin, Swift, and Haskell Programming languages supporting the active development of Bow.

Ensure the success of your project

47 Degrees can work with you to help manage the risks of technology evolution, develop a team of top-tier engaged developers, improve productivity, lower maintenance cost, increase hardware utilization, and improve product quality; all while using the best technologies.