Designing Intuitive APIs with Swift: Harnessing the Power of Custom Type
In one of our projects, I was tasked with formatting product prices. The pricing information will be retrieved from endpoints in JSON format, and my responsibility is to format these prices for display in the user interface. For instance, if a product’s price is 40, it should be presented as “40.00” in the UI. You may think, “Well, why don’t you use SwiftUI’s `Text` with `format` and `fractionLength`?”.
Though some projects might benefit from existing APIs, our project is a bit different. To start with, we cannot set iOS 18 as a minimum for this project. In addition, the current SwiftUI’s format still doesn’t work for many currencies. While the specifics of converting numbers to formatted strings in iOS are beyond the scope of this article, my primary focus here is on API design — specifically, how I create user-friendly APIs via Swift’s type system.
Consider the following JSON structure:
{
“name”: “Product 1”,
“isAvailable”: true,
“qty”: 100,
“price”: 45
}
In order to decode the above, the `Product` struct would look something like the below.
struct Product: Codable {
var name: String
var isAvailable: Bool
var qty: Int
var price: Double
}
Now, there are several ways to format the `price` field.
1. You can add a computed variable or a function called `formatted` in the `Product` type and format the price.
2. Or, you can also add an extension function called `formatted` to the `Double` type to format the price.
3. You can even add a global function called `formatted(price: Double)` that would return a string.
The above methods seem pretty straightforward, but there’s one thing the user of that code (the client) has to do. They have to call the `formatted` either when they’re showing the UI or when they want to export the formatted price to the UI layer. You can see the examples below.
// Call `formatted` at the time of UI presentation (SwiftUI)
Text(product.price.formatted)
// Export formatted price to the UI layer.
extension Product {
var formattedPrice: Double {
price.formattedd
}
}
What if the client side forgot to call/export that formatted string? There can be several structs that have a `price` field; in that case, the client side has to manually call those `formatted` codes.
Luckily, the type system is our saviour! Imagine if the `price` itself is a type called `Price`, and it has a computed variable called `formatted` that returns the formatted string. The `Product` struct would look something like this:
struct Product: Codable {
…
var price: Price
}
And on the UI side, you can simply use the `product.price` directly.
Text(product.price)
This simplifies the process for the caller of this code, as users no longer need to invoke the formatting directly; the API handles all the intricate details for them.
Now, how can we accomplish this? Let me walk you through it step by step.
First, we will create a type called `Price` that will format a double into a string for us.
struct Price {
private(set) var value: Double
init(value: Double) {
self.value = value
}
var formatted: String {
// Your formatting code here
return ""
}
}
Now, how are we going to assign a floating point value to this `Price` type? Like this?
var price: Price = .init(value: 20.0)
That doesn’t seem right. Swift has a protocol called `ExpressibleByFloatLiteral` that would allow you to do something like the following.
var price: Price = 20.0
That looks great! Let’s conform the `Price` to that protocol. It only needs one requirement: a constructor that takes a `FloatLiteralType` and creates a `Price` instance.
struct Price: ExpressibleByFloatLiteral {
…
init(floatLiteral value: FloatLiteralType) {
self.value = value
}
}
Before I go any further, I want you to consider this scenario;
var price: Price = 20
The provided code will give you a compilation error because `20` is an integer value, not a floating-point value. To fix this, you can simply replace `20` with `20.0`. But we can do even better than that! Swift has another protocol called `ExpressibleByIntegerLiteral` that allows you to… well, you tell me. Here’s how it works.
struct Price: ExpressibleByFloatLiteral, ExpressibleByIntegerLiteral {
…
init(integerLiteral value: IntegerLiteralType) {
self.value = Double(value)
}
}
Let’s transition away from the object literal assignment and focus on the encoding and decoding aspects. As we discussed earlier, this is the next step we need to address in the `Product` struct.
struct Product: Codable {
…
var price: Price
}
But here’s the catch: when we need to convert our API’s `number` type to our `Price` type, things get a bit tricky. How do we do this? Well, Swift has got us covered with `init(from:)` and `encode(to:)` methods for encoding and decoding custom types. We want the `JSONEncoder` and `JSONDecoder` to treat our custom type `Price` as a single primitive value. Let’s see how we can make this happen with some code.
extension Price: Codable {
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
self.value = try container.decode(Double.self)
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(value)
}
}
That would handle the encoding and decoding. Are we all set? Well, not quite. What if we want to calculate the total price of products or an average price? We might need to add, subtract, multiply, or divide our prices. Run the code below, and you’ll see a compilation error.
var p1: Price = 10
var p2: Price = 19.5
var total = p1 + p2 // error: Binary operator '+' cannot be applied to two 'Price' operands
We can silence the error by doing something like the following.
var total = p1.value + p2.value
While this solution addresses the issue, it doesn’t come across as natural. When we write APIs or utility code intended for use by other developers, it’s essential to minimise the cognitive load of our APIs. Our goal should be to enable users to perform their tasks effortlessly without encountering unnecessary complexities or pitfalls. It is our responsibility to solve problems, not to create new ones.
Fortunately, Swift, our favourite programming language, provides a protocol called `AdditiveArithmetic`, which allows us to treat the `Price` type like floating point values for basic arithmetic operations.
extension Price: AdditiveArithmetic {
static func + (lhs: Price, rhs: Price) -> Price {
return Price(floatLiteral: lhs.value + rhs.value)
}
static func - (lhs: Price, rhs: Price) -> Price {
return Price(floatLiteral: lhs.value - rhs.value)
}
static func * (lhs: Price, rhs: Price) -> Price {
return Price(floatLiteral: lhs.value * rhs.value)
}
static func / (lhs: Price, rhs: Price) -> Price {
guard rhs.value != 0 else {
assertionFailure("Division by zero is not allowed.")
return Price(floatLiteral: lhs.value)
}
return Price(floatLiteral: lhs.value / rhs.value)
}
}
You might also want to filter the products with a price lower than your target price. Right now, our `Price` type doesn’t support that, but we have another protocol called `Comparable`.
extension Price: Comparable {
static func < (lhs: Price, rhs: Price) -> Bool {
return lhs.value < rhs.value
}
static func > (lhs: Price, rhs: Price) -> Bool {
return lhs.value > rhs.value
}
static func >= (lhs: Price, rhs: Price) -> Bool {
return lhs.value >= rhs.value
}
static func <= (lhs: Price, rhs: Price) -> Bool {
return lhs.value <= rhs.value
}
static func == (lhs: Price, rhs: Price) -> Bool {
return lhs.value == rhs.value
}
}
Our custom `Price` type is now capable of performing essential operations required for this project. To utilise the Price type within the UI, simply add a new initialiser to the SwiftUICore.Text type.
extension Text {
init(_ price: Price) {
self.init(verbatim: price.formatted)
}
}
As a bonus point, you may also want the `Price` to conform to `Sendable` for thread safety. Since the `Price` stored variables and computed variables are all sendable, conforming to this protocol requires no effort. You can also make the `Price` a property wrapper as well.
In conclusion, here are a few takeaways;
- If the existing primitive data types do not meet your specific requirements, consider creating a custom type that aligns with your business logic instead of merely adding extension functions.
- When designing a custom type, aim for a user-friendly experience that minimises cognitive load. For instance, the `Price` type encapsulates formatting logic while still allowing users to perform basic arithmetic operations and comparisons, similar to how they would with `Int` or `Double`. This design also simplifies encoding and decoding processes, eliminating unnecessary complexity for the user.
- Additionally, it is highly beneficial to leverage Swift’s modern concurrency features, such as `Sendable` whenever feasible.