Designing Swift APIs
Everyone is an API designer. While it’s easy to think of APIs as something that’s only relevant for packaged code, like SDKs or frameworks, it turns out that all app developers design APIs almost every single day. Every time we define a non-private property or function we are, in fact, designing an API.
However, designing great APIs can at first be quite tricky. Not only do we have to strike a balance between ease of use and providing enough capabilities, we also need to account for the fact that different people will have varying levels of knowledge within our APIs domain — and there’s also a certain amount of taste involved as well.
This week, let’s take a look at a number of tips and techniques that can be good to keep in mind when designing various APIs in Swift — and how we can create APIs that are both easy to use, and powerful, at the same time.
Context and call sites
One of the key characteristics of a truly great API is that it provides just the right amount of context for it to feel intuitive and natural to use. Add too much context, and the API starts to feel “crufty” and verbose, and with too little context it becomes confusing and ambiguous.
For example, let’s say that we’re building some form of shopping app, and that we’re working on designing the API for one of our key models — the shopping cart. We start by creating a method for mutating a cart by adding a product to it, looking like this:
struct ShoppingCart {
mutating func add(product: Product) {
...
}
}
At first glance it might seem like the above API does indeed strike a nice balance between simplicity and clarity. If we just look at the definition it reads quite nicely: “add product”.
However, when designing APIs, we shouldn’t be looking at the definitions of our properties and methods — we should be looking at how they’ll be used, at the call site, which paints a slightly different picture:
let product = loadProduct()
cart.add(product: product)
The above is not a catastrophe by any means, by it feels a bit unnecessary to have that external product
parameter label there, since it’s already clear that what we’re adding is in fact a product — given the models being used. So let’s go ahead and remove that label by adding an underscore in front of it:
struct ShoppingCart {
mutating func add(_ product: Product) {
...
}
}
It might seem like a nitpicky detail, but the above change does make our call site much nicer to read — like a proper English sentence even — ”cart: add product”:
let product = loadProduct()
cart.add(product)
On the other hand, if the types we’re dealing with don’t make the context crystal clear, then removing external labels can make things quite confusing. Take this method as an example, which enables us to calculate the total price for shipping all the products within a cart to a given address:
extension ShoppingCart {
func calculateTotalPrice(_ address: Address) -> Price {
...
}
}
Again, by just looking at the method definition above, we can figure out that the address will most likely be used to calculate shipping costs, but that’s definitely not clear when reading the call site:
let price = cart.calculateTotalPrice(user.address)
The above almost looks like a programmer error — like the wrong piece of data is passed to the wrong method. That’s a major indication that we haven’t designed a clear enough API. Let’s fix that by adding an external parameter label that clearly states what we’ll be using the address for:
extension ShoppingCart {
func calculateTotalPrice(shippingTo address: Address) -> Price {
...
}
}
Which now gives us the following call site:
let price = cart.calculateTotalPrice(shippingTo: user.address)
Much better! Once again we can verify our API’s clarity by reading it out as an English sentence (with some minor “glue” added): “cart: calculate the total price for shipping to the user’s address.”.
Nested types and overloads
Another tool that can be really useful to have in our “API designer’s toolbox” is nested types. Like we took a look at in “Namespacing Swift code with nested types”, building a hierarchy of related types can be a great way to provide additional context.
Let’s say that we’re adding a new feature to our shopping app, which enables vendors to define bundles of products that can be sold as one unit. Just adding a top-level model called Bundle
would likely not provide enough context to, at a glance, understand that we’re talking about a product bundle — especially given that we’d be in conflict with Foundation’s Bundle
type (which doesn’t give us actual compilation errors, but still isn’t great in terms of clarity).
Let’s instead nest our Bundle
type within Product
— giving us that extra context to make our API crystal clear:
extension Product {
struct Bundle {
var name: String
var products: [Product]
}
}
A big benefit of having clearly named types is that it enables us to use method overloading to define similar APIs, while still maintaining clarity. For example, to add a product bundle to a shopping cart, we could overload our add
API from before — giving us the same nice call site for bundles as well:
extension ShoppingCart {
mutating func add(_ bundle: Product.Bundle) {
bundle.products.forEach { add($0) }
}
}
Anyone using our ShoppingCart
API now only needs to know that the key verb is add
— regardless if what we’re adding is a product, bundle, or anything else. That both makes for quite elegant code, and also makes our API’s learning curve less steep as well.
Strong typing
Naming is important when it comes to API design, but arguably even more important is what actual types that will be involved. Making full use of Swift’s powerful and strong type system can really make our APIs both more intuitive, and less error prone.
Let’s say that the next feature that we wish to add to our ShoppingCart
is support for coupon codes for discounts and promotions. Since users will be entering the actual coupon codes using a text field, an initial idea might be to take the String
from that text field and simply pass it into our shopping cart directly — like this:
extension ShoppingCart {
mutating func apply(couponCode code: String) {
...
}
}
While the above approach is really convenient, it makes it possible for our API to be accidentally used with incompatible input. Since code
is just a plain String
, any string can be passed to it — and since strings are so incredibly common in all programs, chances are quite high that a bad merge, or just a misunderstanding, might lead to something like this:
cart.apply(couponCode: user.name)
The above is obviously wrong, but the compiler won’t warn us about it, since we’re passing a valid string to a method that accepts a string. To fix that, and to make our API a bit more robust, let’s instead introduce a dedicated Coupon
type — which will in turn contain the string-based code as a property.
Doing that will also let us simplify our apply
method, since the types involved now makes the context clear (just like our add
API from before):
struct Coupon {
let code: String
}
extension ShoppingCart {
mutating func apply(_ coupon: Coupon) {
...
}
}
If we again take a look at the call site, the interesting thing is that it reads the exact same way as before (“Cart: Apply coupon code”), but now with a much more type-safe setup:
cart.apply(Coupon(code: "spring-sale"))
While using raw values directly (like strings, integers, etc.) is totally appropriate when we’re dealing with actual text and numbers, for more specific usages the extra “ceremony” of introducing a dedicated type is often worth it.
Scalable APIs
Another aspect that can really make or break an API is how well it scales according to different use cases. Ideally, the most common use case should be really simple, while more advanced ones should be possible by smoothly adding more parameters or customization options.
For example, let’s say that we’re building an ImageTransformer
, that lets us apply various transforms to a UIImage
. Currently, our API looks like this:
struct ImageTransformer {
func transform(_ image: UIImage,
scale: CGVector,
angle: Measurement<UnitAngle>,
tintColor: UIColor?) -> UIImage {
...
}
}
We’re again making use of strong typing above, by using the built-in Measurement
type to express an angle, rather than passing a numeric value directly.
The above works great if we want to scale, rotate and tint an image all at once — but chances are high that in many places we’ll only want to perform one or two specific transforms. To make that possible, without having to always pass “dummy data” to the transforms we’re not interested in — let’s make our API scalable, by adding default values for all arguments except image
.
We’ll also take this opportunity to add external parameter labels for all transforms as well, to make our call sites read really nicely regardless of how many arguments that were supplied:
struct ImageTransformer {
func transform(
_ image: UIImage,
scaleBy scale: CGVector = .zero,
rotateBy angle: Measurement<UnitAngle> = .zero,
tintWith color: UIColor? = nil
) -> UIImage {
...
}
}
// To enable us to simply use '.zero' to create a
// Measurement instance above, we'll add this extension:
extension Measurement where UnitType: Dimension {
static var zero: Measurement {
return Measurement(value: 0, unit: .baseUnit())
}
}
With the above change in place, we’ve now gained a lot of flexibility as to how our API may be used, and all various permutations give us clear call sites — with enough context to see what’s going on:
// Rotate an image
let angle = Measurement<UnitAngle>(value: 180, unit: .degrees)
transformer.transform(image, rotateBy: angle)
// Scale and tint an image
let scale = CGVector(dx: 0.5, dy: 1.2)
transformer.transform(image, scaleBy: scale, tintWith: .blue)
// Apply all supported transforms to an image
transformer.transform(image,
scaleBy: scale,
rotateBy: angle,
tintWith: .blue
)
The only real downside to the above approach is that, since all non-image arguments can now be omitted, it’s possible to call our transform
API with just an image and no transforms — effectively returning the same image again — but that’s a trade-off that’s most likely worth making in this case.
Convenience wrappers
Another way to make an API scalable is to wrap some of its more advanced methods in convenience APIs that perform all of the underlying customization needed within a given situation.
As an example, let’s say that we’re presenting a lot of modal dialogs in our app, and that we’ve written an extension on UIViewController
to make it easier to setup an instance of DialogViewController
for displaying a given dialog:
extension UIViewController {
func presentDialog(ofKind kind: DialogKind,
title: String,
text: String,
actions: [DialogAction]) {
let dialog = DialogViewController()
...
present(dialog, animated: true)
}
}
The above is already a convenience API in of itself, but we can still make it easier to use for some of our most common use cases.
Let’s say that we’re using the above API to present confirmation dialogs in many different places, and that doing so requires us to translate the data within a DialogQuestion
model into a call to the above presentDialog
method. In order to encapsulate that translation logic, and provide yet another level of convenience, let’s create a wrapping method that’s specific for presenting confirmation dialogs:
extension UIViewController {
func presentConfirmation(for question: DialogQuestion) {
presentDialog(
ofKind: .confirmation,
title: question.title,
text: question.explanation,
actions: [
question.actions.cancel,
question.actions.confirm
]
)
}
}
Our above suite of APIs now scales really nicely from the simplest use case (presenting a confirmation dialog), to more advanced ones (presenting any kind of dialog), to completely customizable (creating an instance of DialogViewController
directly). Regardless of what level of control we need in any given situation, we now have an API at our disposal that’s tailored just for that.
Conclusion
What makes a really great API will most likely never be an exact science, since different situations warrant different solutions, and each developer has their own preferred ways of designing and using various APIs.
While the official Swift API guidelines, and tips outlined in articles such as this one, provide a solid starting point — it all comes down to asking ourselves the following question for each one of our APIs: ”Have I done everything in my power to make this API as easy to use, and as clear, as I possibly can?”. Because like it or not, we’re all API designers.
Questions, comments or feedback? Contact me, or send me a tweet.
Thanks for reading! 🚀