Utilising Swift's type system for better API design
When a user attempts to connect to a bluetooth device, it's common practise for an app to scan for peripherals in a two-step process. Firstly, a request is made for specific bluetooth services (such as heart rate). Then, if relevant peripherals are detected nearby, a second request is made for available characteristics (such as heart rate measurement, body sensor location, etc.)
Characteristics are specific to services and contain the relevant values for use within our apps. The GATT specification outlines the entire list of each and everyone.
I wanted to make it simple for users of this library to request the services and associated characteristics they were interested in.
Upon first glance, this problem appeared straight forward. Given the finite nature of services and characteristics, this sounds like a job for enums, right?
Kind of. However, the naive solution quickly descended into large switch statements inside multiple computed properties. Surely there could be a more elegant way to do this.
And there was.
In this post, I'll take you through the approach and thought process that went into this particular API. The end result involved a healthy mix of advanced Swift-y features: protocols, generics, associated types, type constraints.
The most interesting aspect of this project was how the use of these language features revealed themselves bit by bit during development of the API.
Ultimately, the goal I set was to prevent users (mainly myself!) from ever incorrectly requesting characteristics that weren't relevant to a given bluetooth service. This, intuitively, felt like something the type system of Swift could handle on my behalf. And it certainly did.
Let's dive in. I've already mentioned the GATT specification. This contains the 'assigned number' for each and every bluetooth service and characteristic.
Note: GATT assigned numbers relate directly to Core Bluetooth's CBUUID properties on CBService and CBCharacteristic.
We can model each service in a single enum. Typealiasing String is useful for indicating to users of our library it's purpose.
Continuing to focus on battery and heart rate services, let's model their range of characteristics.
Nice and simple. So how could we bring them together?
Time for a protocol
The protocol BluetoothService enforces the rule that services have a 'kind', and one or more characteristics. But we can begin to see some issues with our initial implementation.
Manually adding enum cases to 'allCharacteristics' duplicates efforts. This leaves us open to making simple mistakes. We'd have to make any updates in two places whenever extending our list of services and characteristics.
Similarly, it's possible that any string could be declared for our characteristics array. Not something we want.
Adding an associated type
Let's see how an associated type can help us here.
Here, we're passing the responsibility of defining 'Characteristic' to the conforming types of this protocol. The reason why becomes clear when we look at the implementation.
Moving the definition of battery characteristics inside of our battery service removes the duplication of values. The addition of CaseIterable to our enum also gives us the ability to use allCases for our allCharacteristics property. Reducing the chance for programmer error.
A bonus freebie here is given to us by the Swift compiler. Naming our enum type, Characteristic, the same as the associated type name of the protocol gets picked up and automatically synthesised by the compiler. No need to declare the corresponding typealias explicitly. Pretty neat.
Technically speaking, for our Characteristic type, we could still define anything here. We know this isn't what we want, so let's further constrain our associated type.
Protocol conditional conformance
Here, we've introduced a new type, GATTRepresentable. Despite being an empty protocol, inheritance with conditional conformance fully expresses its intention. How does this improve our implementation?
Further refinement is made by extending BluetoothService and providing a default implementation of 'allCharacteristics'. This allows us to reduce our concrete types to contain only the essential information: service kind and characteristics.
We still have to declare GATTAssignedNumber as the raw value of our enum, however, we do so with the benefit of compiler oversight whenever extending our library's list of services beyond battery and heart rate.
Making requests: Generic types
With this in place, let’s look at how we might go about performing requests for services on the call side. After all, an app might not be interested in all characteristics of a given service. Perhaps only just a few.
Here's where all our efforts of working with protocols, associated types and conditional conformance starts to pay off. We make RequestedService generic so we can specialise it with any BluetoothService-conforming type we like.
The custom initialiser can make use of this type, S, to allow the user to declare any number of associated characteristics they may be interested in - but only the characteristics of the service we have specialised with.
As you can see, autocomplete can now present us with only the characteristics that are relevant.
One last thing
You may be wondering; what was the purpose of 'allCharacteristics' given that we haven't used it? Well, we can make one last refinement to our RequestedService type. Whether using an array or (as we have here) variadic parameters, a user could still pass in zero number of characteristics. This is not a desired state.
To solve, we can use the initialiser body to check for empty, and default to 'allCharacteristics' if true.
“Clarity at the point of use” - Ben Cohen, WWDC 2019
With this post, I wanted to demonstrate a particular approach to API design. Specifically, I wanted to make full use of Swift's type system to achieve my original goals; prevent users from incorrectly requesting unrelated characteristics for any given service.
The outcome is one which I like to think achieves what Ben Cohen highlighted in his "Modern Swift API design" talk at WWDC in 2019; which is to provide clarity at point of use.
The final implementation of this API may make use of some sophisticated Swift language features, but the public interface is inherently simple and readable.