Articles, podcasts and news about Swift development, by John Sundell.

Creating custom query functions using key paths

Published on 04 Jan 2021

Being a fairly strict, statically compiled language, it might not initially seem like Swift offers much in terms of syntax customization, but that’s actually quite far from the case. Through features like custom and overloaded operators, key paths, function/result builders, and more, there are a lot of opportunities for us to tweak Swift’s syntax for particular use cases.

Of course, it could also definitely be argued that any kind of syntax customization should be approached with great caution, as non-standard syntax could also easily become a source of confusion if we’re not careful. But, in certain situations, that tradeoff might be worth it, and can sort of let us craft “micro-DSLs” that can actually help us make our code more clear, rather than the opposite.

Raycast

Raycast: Take the macOS Spotlight experience to the next level: Create Jira issues, manage GitHub pull requests and control other tools with a few keystrokes. Easily automate every-day tasks and boost your developer productivity by downloading Raycast for free.

Negated boolean key paths

To take a look at one such case, let’s say that we’re working on an app for managing, filtering and sorting articles, which features the following Article data model:

struct Article {
    var title: String
    var body: String
    var category: Category
    var isRead: Bool
    ...
}

Now let’s say that a very common task within our code base is to filter various collections that each contain instances of the above model. One way to do that would be to use the fact that any Swift key path literal can be automatically converted into a function, which lets us use the following compact syntax when filtering on any boolean property, such as isRead in this case:

let articles: [Article] = ...
let readArticles = articles.filter(\.isRead)

That’s really nice, however, the above syntax can only be used when we want to compare against true — meaning that if we wanted to create a similarly filtered array containing all unread articles, then we’d have to use a closure (or pass a function) instead:

let unreadArticles = articles.filter { !$0.isRead }

That’s certainly not a big problem, but if the above kind of operation is something that we’re performing in many different places across our code base, then we might start to ask ourselves: “Wouldn’t it be great if we could also use that same nice key path syntax for negated booleans as well?”

This is where the concept of syntax customization comes in. By implementing the following prefix function, we can actually create a small little tweak that’ll let us use key paths regardless if we’re comparing against true or false:

prefix func !<T>(keyPath: KeyPath<T, Bool>) -> (T) -> Bool {
    return { !$0[keyPath: keyPath] }
}

The above is essentially an overload of the built-in ! prefix operator, which makes it possible to apply that operator to any Bool key path in order to turn it into a function that negates (or flips) its value — which in turn now lets us compute our unreadArticles array like this:

let unreadArticles = articles.filter(!\.isRead)

That’s quite cool, and doesn’t really make our code confusing, given that we’re using the ! operator in a way that’s consistent with how it’s used by default — to negate a boolean expression.

Key path-based comparisons

Now, to take things even further, let’s also make it possible to use key paths to form filter queries that compare a given property against any kind of Equatable value. That’ll become useful if we, for example, wanted to filter our articles array based on each article’s category. The type of that property, Category, is currently defined as an enum that looks like this:

extension Article {
    enum Category {
        case fullLength
        case quickReads
        case basics
        ...
    }
}

Just like how we previously overloaded the ! operator with a key path-specific variant, we can do the same thing with the == operator as well, and just like before, we’ll return a Bool-returning closure that can then be directly passed to APIs like filter:

func ==<T, V: Equatable>(lhs: KeyPath<T, V>, rhs: V) -> (T) -> Bool {
    return { $0[keyPath: lhs] == rhs }
}

With the above in place, we can now easily filter any collection using a key path-based comparison, like this:

let fullLengthArticles = articles.filter(\.category == .fullLength)

Support Swift by Sundell by checking out this sponsor:

Raycast

Raycast: Take the macOS Spotlight experience to the next level: Create Jira issues, manage GitHub pull requests and control other tools with a few keystrokes. Easily automate every-day tasks and boost your developer productivity by downloading Raycast for free.

Conclusion

Depending on who you ask, the fact that Swift lets us easily create the above kind of functionality through a couple of lightweight overloads is either really awesome or incredibly concerning. I tend to fall somewhere in the middle, and think that it is indeed really great that we can make minor domain-specific tweaks to Swift’s syntax, but at the same time, I think those tweaks should always be made with the goal of making our code simpler, not more complex.

Like with all things I write about here on Swift by Sundell, I’ve personally used the above technique in a few projects in which I had to do a lot of collection filtering, and it’s been quite wonderful, but I wouldn’t deploy it unless I had a strong need for that kind of functionality.

For a much more thorough, and also more advanced, variant of the above technique, check out “Predicates in Swift”, and feel free to send me your questions and comments via either Twitter or email.