Writing reusable Swift extensions
Discover page available: GenericsArguably one of Swift’s most interesting and powerful features is how it lets us extend any type or protocol with new functionality. Not only does that let us tweak the language and its standard library to fit each projects’ needs, it also opens up many different opportunities for writing extensions that could be reused across multiple use cases and projects.
This week, let’s take a look at a few examples of doing just that, as well as a set of principles that can be good to keep in mind when generalizing an extension to be usable in a much wider context.
Generalizing through abstractions
When writing code on a day-to-day basis, it’s very common for each new feature and piece of functionality to start its life as a very domain-specific implementation. There’s nothing wrong with that, in fact, it helps us avoid “premature generalization” and often lets us iterate much faster by initially focusing on just a single use case.
For example, let’s say that we’re working on a text editor for writing articles — and that in order to improve our app’s performance in certain scenarios, we’ve written the following function to enable us to easily cache a given article to disk:
extension Article {
func cacheOnDisk() throws {
let folderURLs = FileManager.default.urls(
for: .cachesDirectory,
in: .userDomainMask
)
let fileName = "Article-\(id).cache"
let fileURL = folderURLs[0].appendingPathComponent(fileName)
let data = try JSONEncoder().encode(self)
try data.write(to: fileURL)
}
}
The above caching implementation is just an example, for a much more thorough look at building various forms of caching systems in Swift, check out “Caching in Swift”.
Extending specific types with the above kind of utilities can be a great way to both reduce code duplication, and to make it easier to perform common tasks across a code base. However, a concrete type extension can also sometimes be a bit of a missed opportunity to make our code less decoupled and more flexible. In this particular case, chances are very high that we’ll not only want to cache Article
instances, but also other kinds of models as well — which our current implementation won’t let us do.
When looking to generalize a specific extension, it may first seem like the work that’s being done is indeed strongly tied to the concrete type that’s being extended. For example, our above caching function uses our Article
type’s name, and its id
property, to form each fileName
. However, conceptually, there’s really nothing about those two pieces of data that’s specific to articles — every type has a name, and any type can have an id
property, so we should be able to make that extension reusable.
Let’s start by reviewing what our caching function’s requirements actually are:
- In order to be able to encode each value into a JSON representation, we need any type that’s going to be used with our function to conform to the standard library’s
Encodable
protocol. - We also need each compatible type to have an
id
property, to be able to compute a uniquefileName
for each value that’s being cached.
To model those two requirements in a way that’s not directly tied to any concrete type, we’ll use Encodable
as the new base target for our extension, and we’ll then add a generic type constraint to specify that our caching method may only be called on types that also conform to Identifiable
— another standard library protocol, which gives us the id
property that we need:
extension Encodable where Self: Identifiable {
// We also take this opportunity to parameterize our JSON
// encoder, to enable the users of our new API to pass in
// a custom encoder, and to make our method's dependencies
// more clear:
func cacheOnDisk(using encoder: JSONEncoder = .init()) throws {
let folderURLs = FileManager.default.urls(
for: .cachesDirectory,
in: .userDomainMask
)
// Rather than hard-coding a specific type's name here,
// we instead dynamically resolve a description of the
// type that our method is currently being called on:
let typeName = String(describing: Self.self)
let fileName = "\(typeName)-\(id).cache"
let fileURL = folderURLs[0].appendingPathComponent(fileName)
let data = try encoder.encode(self)
try data.write(to: fileURL)
}
}
Just like that, we’ve converted our caching method from a very Article
-specific implantation into a completely reusable one — and since our new extension only depends on protocols and abstractions defined by Swift’s standard library, we could also now share it between code bases that need the same sort of functionality.
Finding the right protocol
Let’s take a look at another example, in which we’ve again written an Article
-specific extension, this time on the standard library’s Result
type — to enable a result instance carrying an array of Article
values to be easily combined with another instance of the same type:
extension Result where Success == [Article] {
func combine(with other: Self) throws -> Self {
try .success(get() + other.get())
}
}
Just like our caching method from before, there’s really nothing about the above combine
method that requires it to know about Article
as a concrete type. In this case, all we need is to be able to combine two underlying collections of values into one — which is something that can be done with any type that conforms to RangeReplaceableCollection
.
So let’s again replace our concrete type requirement with a much more generic constraint, and this time we don’t even need to change our actual implementation at all:
extension Result where Success: RangeReplaceableCollection {
func combine(with other: Self) throws -> Self {
try .success(get() + other.get())
}
}
What both of the above two examples demonstrate is that it’s often possible to generalize a utility extension into something that’s much more general-purpose — by picking one of the standard library’s many built-in abstractions as our extension’s target, rather than using one of our own, concrete types. By doing so, we not only make it possible to reuse our functionality among our own types, but we could potentially also share it between projects — and even open source it.
Avoiding conflicts and type pollution
So far, the extensions that we’ve defined have all contained the actual functionality that we wanted to add, but that’s not always the case. For example, sometimes the entire purpose of an extension is to retrofit an existing type with a protocol conformance, or to make it compatible with a custom system or API.
Let’s take a look at such a scenario, in which we’re building a general-purpose storage framework that’ll enable various projects to save and load different values using a Container
type. To be able to write a given piece of data into one of those containers, we’ve defined a DataConvertible
protocol that we’ve then made several system types conform to — like this:
public protocol DataConvertible {
var data: Data { get }
}
extension Data: DataConvertible {
public var data: Data { self }
}
extension String: DataConvertible {
public var data: Data { Data(utf8) }
}
extension UIImage: DataConvertible {
public var data: Data { pngData()! }
}
public struct Container {
public func write(_ value: DataConvertible) throws {
let data = value.data
...
}
}
The above approach might work reasonably well within a single code base, but if our intention is for this functionality to be shared among multiple projects, it’ll most likely end up becoming quite problematic.
Since we’ve defined our property requirement simply as data
, chances are high that it’ll end up colliding with other property definitions that have the same name. That’s also true for our protocol itself, which has the very generic name DataConvertible
— which could become ambiguous in a larger context. While type names can always be disambiguated using ModuleName.TypeName
, property names cannot.
One potential solution to this problem would be to remove our protocol (and its associated extensions) altogether, and instead add a number of type-specific overloads to our Container
type, like this:
public struct Container {
public func write(_ data: Data) throws {
...
}
public func write(_ string: String) throws {
try write(Data(string.utf8))
}
public func write(_ image: UIImage) throws {
guard let data = image.pngData() else {
throw Error.failedToConvertImageToPNGData
}
try write(data)
}
}
While the above approach works great as long as the number of types that we’re supporting remains fairly low, things can start to get tricky in case we ever want to add more configuration options and parameters to our container API.
For example, let’s say that we wanted to enable our API users to specify what level of persistence to use when writing a given value, or to associate a set of tags with it. Just those two minor additions would already make our implementation a lot more complicated, and cause a fair amount of code duplication — as we’d need to add both of those parameters (and their default values) to every single overload:
public struct Container {
public func write(_ data: Data,
persistence: Persistence = .permanent,
tags: [Tag] = []) throws {
...
}
public func write(_ string: String,
persistence: Persistence = .permanent,
tags: [Tag] = []) throws {
let data = Data(string.utf8)
try write(data,
persistence: persistence,
tags: tags
)
}
public func write(_ image: UIImage,
persistence: Persistence = .permanent,
tags: [Tag] = []) throws {
guard let data = image.pngData() else {
throw Error.failedToConvertImageToPNGData
}
try write(data,
persistence: persistence,
tags: tags
)
}
}
As it turns out that internalizing all possible argument combinations within our Container
type isn’t necessarily great either — let’s go back and explore the protocol-oriented route a bit further.
Since our main problem before was that our protocol and its requirement were named in a way that could potentially cause conflicts — let’s instead use a slightly more verbose naming convention, which explicitly includes the word “Container”. Let’s also make our protocol requirement a throwing function, which lets us avoid any force unwrapping when converting a UIImage
into Data
:
public protocol ContainerDataConvertible {
func asContainerData() throws -> Data
}
extension Data: ContainerDataConvertible {
public func asContainerData() -> Data {
self
}
}
extension String: ContainerDataConvertible {
public func asContainerData() -> Data {
Data(utf8)
}
}
extension UIImage: ContainerDataConvertible {
public func asContainerData() throws -> Data {
guard let data = pngData() else {
throw Container.Error.failedToConvertImageToPNGData
}
return data
}
}
Note that not just because a protocol function is marked as throws
doesn’t mean that all implementations of it need to throw.
With the above change in place, chances are now much lower that our extension will end up causing problems when mixed with other code bases, and we’re back to Container
having just a single write
method that can handle any ContainerDataConvertible
-conforming type:
public struct Container {
public func write(
_ value: ContainerDataConvertible,
persistence: Persistence = .permanent,
tags: [Tag] = []
) throws {
let data = try value.asContainerData()
...
}
}
That’s already much better, but perhaps we could go one step further in terms of making our system extensions as unobtrusive as possible. Up until now, we’ve been adding instance properties and methods to the types that are being retrofitted, which makes those additions quite visible and likely to show up as autocompletion results (even though they’re more or less intended to be implementation details of Container
).
To address that, let’s instead define our protocol requirement as a static function — and implement it on the actual types themselves, rather than adding it to all instances of them:
public protocol ContainerDataConvertible {
static func makeContainerData(for value: Self) throws -> Data
}
extension Data: ContainerDataConvertible {
public static func makeContainerData(for value: Data) -> Data {
value
}
}
extension String: ContainerDataConvertible {
public static func makeContainerData(for value: String) -> Data {
Data(value.utf8)
}
}
extension UIImage: ContainerDataConvertible {
public static func makeContainerData(for value: UIImage) throws -> Data {
guard let data = value.pngData() else {
throw Container.Error.failedToConvertImageToPNGData
}
return data
}
}
The above implementation still lets us maintain just a single write
function, all without having to add any additional complexity to the instances of our compatible types — since we’ll now simply call makeContainerData
directly on each value’s type, like this:
public struct Container {
public func write<T: ContainerDataConvertible>(
_ value: T,
persistence: Persistence = .permanent,
tags: [Tag] = []
) throws {
let data = try T.makeContainerData(for: value)
...
}
}
Especially when it comes to making a number of system types compatible with a custom API or framework, adding the required complexity in a static context can be a great way to better encapsulate all of those implementation details, and can let us avoid “polluting” the instances of those types.
Conclusion
A big part of enabling a given extension to be reused often comes down to picking the right level of abstraction for it, and the more we can rely on the standard library to act as a common denominator for all compatible types, the more portable our extensions are likely to become.
However, sometimes we do need to introduce our own custom protocols when sharing extensions between projects, and there’s nothing wrong with that — as long as we make sure that we’re doing everything that we can to avoid adding additional cruft or potential sources of conflict to any system types that we’re making conform to those protocols.
What do you think? How do you currently use extensions in your projects, and do you tend to share any of your extensions between projects? Let me know — along with your questions, comments and feedback — either on Twitter or via email.
Thanks for reading! 🚀