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

Specialized extensions using generic type constraints

Published on 21 Oct 2021
Discover page available: Generics

Combining Swift’s powerful generics system with the fact that any Swift type can be extended with new APIs and capabilities enables us to write targeted extensions that conditionally add new features to a type or protocol when it fits certain requirements.

It all starts with the where keyword, which lets us apply generic type constraints in a range of different situations. In this article, let’s take a look at how that keyword can be applied to extensions, and what sort of patterns that can be unlocked by doing so.

Constraining an extension based on a generic type

One of the ways in which we can extend a generic type or protocol with more specific APIs is by applying a where-based type constraint to the extension itself. For example, here we’re extending the standard library’s Sequence protocol (which collections like Array and Set conform to) with a convenience API that lets us render a series of Renderable-conforming types by calling render() directly on a sequence — as long as that sequence contains such Renderable elements:

protocol Renderable {
    func render(using renderer: inout Renderer)
}

extension Sequence where Element == Renderable {
    func render() -> UIImage {
        var renderer = Renderer()

        for member in self {
            member.render(using: &renderer)
        }

        return renderer.makeImage()
    }
}

To learn more about the inout keyword that’s used above, and how it enables us to pass a reference to a value within certain contexts, check out “Utilizing value semantics in Swift”.

The benefit of the above pattern is both that it makes the internal implementation of our added render method completely type-safe (because we have a compile-time guarantee that we’ll always be working with Renderable elements), and that we now get access to a neat convenience API whenever we want to render an array of Renderable-conforming values:

class CanvasViewController: UIViewController {
    var renderables = [Renderable]()
    private lazy var imageView = UIImageView()
    ...

    func render() {
        imageView.image = renderables.render()
        ...
    }
}

Another type of situation in which it can be really useful to write a type-constrained extension is when we want to conditionally make a generic type conform to a protocol. For example, here’s how we could make Swift’s standard Array type conform to the above Renderable protocol only when it, in turn, contains Renderable elements:

extension Array: Renderable where Element: Renderable {
    func render(using renderer: inout Renderer) {
        for member in self {
            member.render(using: &renderer)
        }
    }
}

With the above in place, we’re now able to use nested arrays of Renderable values (which could have great benefits in terms of grouping), while still being able to render our top-level renderables array just like before:

extension CanvasViewController {
    func userDidDrawShapes(_ shapes: [Shape]) {
        renderables.append(shapes)
        render()
    }
}

The above patterns are used extensively within Swift’s standard library (for example to make Array conform to protocols like Equatable and Codable when its elements also conform to those protocols), and can also be really useful within our own code as well — especially when building custom libraries.

Self constraints

Type-constrained extensions can also enable us to add default protocol implementations that can only be used by types that fulfill certain requirements. For example, here we’re providing a default implementation of a Dismissable protocol’s dismiss method when that protocol is being conformed to by a UIViewController subclass:

protocol Dismissable {
    func dismiss()
}

extension Dismissable where Self: UIViewController {
    func dismiss() {
        dismiss(animated: true)
    }
}

The benefit of the above pattern is that it lets us opt our view controllers in to being Dismissable, rather than adding that method to all UIViewController instances within our entire app. At the same time, because of our extension, we don’t need to actually re-implement the dismiss method within every single view controller, but can rather just conform to our new protocol and utilize its default implementation:

class ProductViewController: UIViewController, Dismissable {
    ...
}

We still have the option to provide a custom dismiss implementation where needed, and for types that aren’t UIViewController subclasses, providing such a dedicated implementation is required (since those types don’t get access to our constrained extension’s default implementation):

class AlertPresenter: Dismissable {
    func dismiss() {
        ...
    }
}

Applying constraints to individual functions

Finally, let’s also take a look at how we can not only apply generic type constraints to an extension as a whole, but also to individual functions within such an extension. For example, if we wanted to, we could’ve written our Sequence extension from before like this instead:

extension Sequence /*where Element == Renderable*/ {
    func render() -> UIImage where Element == Renderable {
        ...
    }
}

In the above situation, it doesn’t really matter whether we apply our generic type constraint to our extension, or directly to our function — both approaches give us the exact same effect. However, that’s not always the case. To explore further, let’s say that we’re working on an app that contains the following Group protocol, which uses a generic associatedtype to enable each group to define what type of Member values that it contains:

protocol Group {
    associatedtype Member
    var members: [Member] { get }
    init(members: [Member])
}

Then, let’s say that we wanted to create a simple API for combining two groups by merging their members arrays — which could be done without using any form of generic constraints, for example like this:

extension Group {
    func combined(with other: Self) -> Self {
        Self(members: members + other.members)
    }
}

However, the above extension does require the two groups that are being combined to be of the exact same type. That is, it’s not enough for them to contain the same type of Member values — the groups themselves need to match, which might not be what we want.

So, to address that, let’s instead modify the above extension to use a generic type constraint directly attached to our combined method, which enables both groups to be of different types while still requiring their Member types to be the same:

extension Group {
    func combined<T: Group>(
        with other: T
    ) -> Self where T.Member == Member {
        Self(members: members + other.members)
    }
}

With the above in place, we can now easily combine groups however we wish, as long as those groups are storing the same kind of values. For example, here we’re combining an ArticleGroup instance with a FavoritesGroup, which is possible since they both store Article values:

let articles: ArticleGroup = ...
let favorites: FavoritesGroup = ...
let combined = articles.combined(with: favorites)

Neat! While the above pattern is certainly more specific than the others we took a look at throughout this article, it can be incredibly useful when doing more advanced generic programming in Swift.

Conclusion

Swift’s version of both generics and extensions are incredibly useful on their own, but when combined, they can enable us to adopt some even more interesting and powerful patterns. Of course, not every code base needs to take advantage of these capabilities — and it’s really important to not over-generalize the code that we write — but, when warranted, type-constrained extensions can be a great tool to have in our Swift developer’s toolbox.

Hope you found this article useful, and if you have any questions, comments, or feedback, feel free to reach out via email.

Thanks for reading!