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.
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.
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() {
...
}
}
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 {
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.
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!