It’s no secret that the best way to win a developer’s heart is by enhancing their developer experience (DevEx). I believe that introducing clear, consistent, and robust API design, in addition to implementing a well-fitting architecture, is something we cannot afford to overlook.
Creating a smooth and intuitive experience for developers when writing Swift code comes down to the names, idioms and patterns you use in your APIs. These design guidelines help ensure that your code fits seamlessly into the larger Swift ecosystem, making it feel natural and familiar to other developers.
You might think that API design is more fitting for packaged code like libraries, packages, frameworks, and SDKs. However, what if I told you that every time you write a non-private property or function, you are, in fact, writing an API?
Real World Example
Let’s take a look at a real-world example that I actually encountered while maintaining a chat app for a company I worked for in the past:
struct ChatManager {
func sendMessageToUser(message: Message,
user: User,
otherUser: User,
messageOptions: MessageOptions) {
// ...
}
}
While this code does have a somewhat clear intent—to send a message to a user—the call site can be extremely confusing. Who is user: User
? Is it the current user or the one I’m messaging? And who is otherUser: User
? Does it refer to an additional user I’m sending the message to in some strange way?
You can see where this is going and how confusing it can get.
So… how do we make this API better?
We’ll get to that in a later section. First, let’s dive into a few basic guidelines with some code examples to better understand our intentions.
Naming, Call Sites & Context
Clear naming in context is a fundamental rule for writing clean code, helping make our intentions obvious to ourselves and other developers.
Let’s create a new VideoLibrary
and, within it, add an API method that adds a new Video
to the library:
struct VideoLibrary {
mutating func add(video: Video) throws {
// ...
}
}
At first glance, this looks pretty good and clear. However, we still need more context. Also, the video:
argument name might be redundant, like this:
videoLibrary.add(video: video) // Redundant: No reason to read `video` twice
One way to improve our code is by using argument labels. Swift allows us to add argument labels to method and function arguments, which helps provide clearer context and intent:
mutating func add(_ video: Video) throws { // Adde an empty label using an underscore
// ...
}
// Usage
videoLibrary.add(video)
As you can see at the call site, an empty label removes the need to provide a argument name before the value. However, the call site is still unclear. What if our add
API method actually ignores the video we provide if it already exists and throws an error?
// We've added an empty label using underscore
mutating func add(_ video: Video) throws {
if videos.contains(video) {
throw VideoLibraryError.videoAlreadyExists
}
// ...
}
We could fix this by adding a label that provides a clearer intent:
mutating func add(new video: Video) throws {
if videos.contains(video) {
throw VideoLibraryError.videoAlreadyExists
}
// ...
}
We’ve added the label new
to our API method. This way, we have provided a clearer intent to the fact that the input Video
must be new to the data source.
Another example could be introducing a new API method in our VideoLibrary
to calculate the user’s remaining disk space in their cloud service, in case they want to sync their videos
extension VideoLibrary {
func calculateDiskSpace(_ service: CloudService) -> DiskSpace {
// ...
}
}
As you can guess, the call site can become very unclear:
let diskSpace = videoLibrary.calculateDiskSpace(user.cloudService) // Unclear: what is the intention of the returned `DiskSpace`?
We can definitely better this using our newly learned method to provide a label to the call side like so:
func calculateDiskSpace(leftIn service: CloudService) -> DiskSpace
let diskSpace = videoLibrary.calculateDiskSpace(leftIn: user.cloudService)
This simple leftIn
label, provides clearer context and intent on what we are going to receive in return.
Nested Types
Swift lets us do something really cool: the ability to nest types within each other, which helps clarify relationships between types.
Let’s create a new type called Album
in addition to our existing Video
type. The Album
will be a collection of videos with a few additional metadata arguments:
struct Video {
let name: String
let author: Author
}
struct Album {
let name: String
let videos: [Video]
}
Now, you might argue that it’s obvious in the global context that an Album
relates to Video
. But what if your Product Manager now wants to introduce an Audio
album with different arguments? Are you going to rename your type to VideoAlbum
to accommodate this change? Maybe. But there’s another option—use nested types.
extension Video {
struct Album {
let name: String
let videos: [Video]
}
}
extension Audio {
struct Album {
let name: String
let artist: String
let genre: String
let audios: [Audio]
}
}
By moving our struct Album { ... }
type to be nested within the Video
type declaration, we can now declare and initialize it in this context:
let videoAlbum = Video.Album(name: "Family vacation 2024", videos: [...])
let audioAlbum = Audio.Album(name: "Meteora", artist: "Linkin Park", genre: "Alternative Rock", audios: [...])
This option provides an easier way to keep namings short, clear and have direct relationships to their counterpart types.
💡 Of course, there’s also the option of introducing generics to our Album type, but that approach is beyond the scope of this article.
Functions Overloading
Function (or method) overloading is a practice that can be found in many programming languages, but Swift makes it easy by allowing us to use the same function name with different parameters, thus treating it as a totally different method signature.
Let’s introduce a new API to our VideoLibrary
that enables us to add a new Album
type:
struct VideoLibrary {
mutating func add(new video: Video) throws {
// ...
}
mutating func add(new album: Video.Album) throws {
// ...
}
}
As you can see, we have reused the add
method naming convention but used a totally different argument. So now, when we call the .add
, we can have two types of adding methodologies:
videoLibrary.add(new: video)
videoLibrary.add(new: album)
Strong Typing
Some API method argument types can be clear enough just by using some built-in strong types (such as String
, Int
, and more). For example if we just ask for an age in update(age: Int, for...)
, but how can we improve it for more complex types and still maintain a readable flow?
Consider the following API method we have added to our VideoLibrary
in order to update an author
for a specific Video
:
extension VideoLibrary {
mutating func update(author: String, for video: Video) {
// ...
}
}
videoLibrary.update(author: "George Lucas")
Let’s create a new type named Author
and have it contain a name
of type String
as a member and update our API method:
struct Author {
let name: String
}
extension VideoLibrary {
mutating func update(author: Author, for video: Video) {
// ...
}
}
videoLibrary.update(author: Author(name: "George Lucas"), for: video)
Now it makes it much more readable as a sentence, but something is still off. Let’s try to omit the author
label like so:
mutating func update(_ author: Author, for video: Video) {
// ...
}
// Usage
videoLibrary.update(Author(name: "George Lucas"), for: video)
Now, you can see that within the call site, it feels like reading a complete English sentence:
Update author name George Lucas for
video
.
Although it’s broken English, I believe you can understand the point 🤭
Default & Optional Method Arguments
Optionals in Swift are a great way to omit or declare that some values can be missing. These can also be used for method arguments in Swift.
Optional
: A type that represents either a wrapped value or the absence of a value. — Apple Documentation
Additionally, default value arguments are also available in Swift, providing an option to pass a default value in case a value is missing, but we don’t actually want it to be nil
.
Consider the following Network
manager that can be used to send requests to a URL
:
struct Network {
func request(
url: URL,
method: String,
headers: [HTTPHeader],
queryItems: [URLQueryItem],
body: [String : any]
) {
// ...
}
}
We can improve this request
API method using Optional
s and default values:
struct Network {
func request(
_ url: URL, // Omit the `url` label
method: HTTPMethod = .get, // defaulted to `.get` if not provided
headers: [HTTPHeader]? = nil, // optional
queryItems: [URLQueryItem]? = nil, // optional
body: [String : any]? = nil // optional
) {
// ...
}
}
// Default GET request
networking.request(url)
// GET request with headers
networking.request(url, headers: headers)
// POST request with all arguments
networking.request(url,
method: .post,
headers: headers,
body: body)
Additionally, it is mostly considered a good practice to place optional arguments as close as possible to the end of the method/function.
Note that because we have defaulted some Optional
arguments to nil
, we don’t have to provide them inside the call site at all. Very convenient.
Making our Chat App API method better
Remember this API method from the “Real World Example” section?
struct ChatManager {
func sendMessageToUser(message: Message,
user: User,
otherUser: User,
messageOptions: MessageOptions) {
// ...
}
}
Now, after we have learned and understooed some cool practices we can start treating this abomination and make it much simpler to implement.
Let’s introduce a few changes from what we have learend thus far:
- Omit the need to provide a
message
argument name - Add clearer context as to
from
whoto
who this message is going to be - The ability to provide
options
should be totally optional. Also note that we nested the typeOptions
within ourMessage
type for even clearer context
struct ChatManager {
func send(_ message: Message, // 1
from currentUser: User, // 2
to destinationUser: User,
with options: Message.Options? = nil /* 3 */) {
// ...
}
}
And now, let’s inspect its call site in a before & after manner:
// Before
chatManager.sendMessageToUser(message: message,
user: currentUser,
otherUser: destinationUser,
messageOptions: options)
// After
chatManager.send(message,
from: currentUser,
to: destinationUser,
with: options)
// We can also omit the ‘with options' argument if we don't need to pass it
chatManager.send(message,
from: currentUser,
to: destinationUser)
Where to go from here?
While designing APIs isn’t an exact science, understanding the fundamentals and recognizing the need to make our code clearer and more intentional—for ourselves and other developers—can go a long way. By mastering the tools at your disposal, you’ll set yourself on the path to becoming a much better engineer.
If you have any more interest in the official Swift conventions and guideliness, you can read more about it in the official Swift API Design Guidelines page.
👨🏻💻 Happy coding!