Weekly Swift articles, podcasts and tips by John Sundell.

Writing self-documenting Swift code

Published on 27 May 2018

When and how to write documentation and code comments tends to be something that causes a lot of debate among developers. Some people prefer all code to be heavily documented and commented, in an effort to make things more clear and easier to maintain, by leaving a "paper trail" of what the intent of the code was when it was written.

On the other hand, other people feel like writing documentation is a waste of time, and actually makes things harder to maintain since comments and documentation often tends to become outdated as implementation details change.

Regardless of how you feel about writing (and reading) documentation - I think most of us agree that we should always try to make our code, and the APIs we design, as easy to understand as possible. We all want to avoid that situation when we end up with code that no one on the team understands, either because the original authors left the company, or simply because no one remembers the details of what the code was originally supposed to do.

This week, let's take a look at a few simple tips and tricks that can let us write code that is more self-documenting - code that makes the underlying intent and details more clear, simply by the way it's structured and how it's written.

Breaking things up

The systems that tend to cause the most amount of confusion over time are the ones that are massive blobs of logic. The more massive and complex things become, the harder they generally are to reason about. Also, as things grow, details tend be lost in the mix - leading to more unclear behaviors and bugs.

Let's start by taking a look at an example. Here we have a StoreViewController that consists of 3 parts - a header view, a product list and an actions view. Currently, all those three parts are setup in the view controller's viewDidLoad() method, like this:

class StoreViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()

        // Setup header view
        ...

        // Setup product list
        ...

        // Setup actions view
        ...
    }
}

While the above implementation might start out quite simple, as new features are added and the UI becomes more complex, these methods tend to grow enormous quite quickly. Chances are if we expand those ... above we'll find hundreds of lines of setup code, all in one massive method, making things both hard to understand and very fragile - as one little change might cause a bug in another part of the setup code.

So how can we fix the above? One way is to simply move the setup of each part into its own dedicated method, giving us three much smaller setup methods that are each much easier to reason about and maintain:

private extension StoreViewController {
    func setupHeaderView() {
        ...
    }

    func setupProductsView() {
        ...
    }

    func setupActionsView() {
        ...
    }
}

Another way, which is my personal favorite, is to use view controller composition and create a dedicated child view controller for each part of our store UI:

class StoreViewController: UIViewController {
    private let header = HeaderViewController()
    private let productList = ProductListViewController()
    private let actions = ActionsViewController()

    override func viewDidLoad() {
        super.viewDidLoad()
        add(header, productList, actions)
    }
}

The above add method is a variant of the convenience API that was introduced in "Using child view controllers as plugins in Swift".

That way, each child view controller can now contain all setup required for each part of our StoreViewController - making the entire UI a lot easier to handle, change and maintain, without adding any additional comments or documentation.

Clear APIs

Another common source of complexity and confusion is unclear APIs. Like I talked about in my talk "Everyone is an API designer", creating clear and focused APIs between our types can really help make code bases a lot easier to understand - again without adding any additional documentation.

Here we have a FriendsManager, which lets us look up the current user's friends by name:

class FriendsManager {
    private(set) var friends = [String : Friend]()
}

By looking at the above property, it's not very clear that the keys in the friends dictionary are actually names. They could be IDs, usernames, or any other string for all we know. Without documentation the above API becomes tricky to use, and it's easy to accidentally cause a bug by using it "the wrong way".

Instead, let's make the API a bit more clear and focused - by turning the underlying dictionary private (it is, after all, an implementation detail how friends are actually stored), and instead exposing a dedicated method for retrieving a friend by name, like this:

class FriendsManager {
    private var friendsByName = [String : Friend]()

    func friend(named name: String) -> Friend? {
        return friendsByName[name]
    }
}

As you can see above, we also take the opportunity to make the name of the dictionary property a bit more clear as well. Even though it's no longer part of the public API, by naming it friendsByName it becomes very clear to anyone working with this code in the future that the friends are indeed stored by name and nothing else.

Dedicated types

Designing clear APIs is of course not only about how we name our methods and how we draw the line between implementation and public interface - it's also a lot about how we structure our types and how we leverage the type system and compiler to make sure that our code is correct.

Like we took a look at in "Type-safe identifiers in Swift", creating dedicated types for things like identifiers can really help us avoid mistakes and confusion when dealing with string-based values. The same can also be true for other primitive-based types as well. Let's take a look at an example in which we use a Bool to indicate whether push notifications were successfully turned on:

protocol PushNotificationService {
    func enablePushNotifications(then handler: @escaping (Bool) -> Void)
}

When originally writing the above code, it might seem super clear that the Bool passed to the handler indicates whether push notifications were indeed turned on, but it's again something that could cause confusion or make us feel the need to write additional documentation.

Instead of using a plain Bool, one way we could make the above method more explicit is to use a dedicated type, for example an enum that lets us clearly express what the outcome of the operation was:

enum PushNotificationStatus {
    case enabled
    case disabled
}

Now, when we read the method signature, it becomes crystal clear that the value that gets passed to our handler is in fact the resulting push notification status:

protocol PushNotificationService {
    typealias Handler = (PushNotificationStatus) -> Void

    func enablePushNotifications(then handler: @escaping Handler)
}

It might seem like a trivial detail, but especially when we take a look at the call site, the advantages of using an enum instead of a Bool become a lot more obvious:

service.enablePushNotifications { [weak self] status in
    // Since we now use an enum, we are "forced" to deal with
    // both potential outcomes, making our code more clear and
    // also more robust as well.
    switch status {
    case .enabled:
        self?.removeNotificationsButton()
    case .disabled:
        self?.showNotificationsDisabledLabel()
    }
}

Another example of using the type system to write code that is more self-documenting is when dealing with values that have the same underlying representation, but should be treated separately. For example, let's say our app is using an OAuth-based login system, and we have defined a protocol for dealing with authorization and the resulting access and refresh tokens:

protocol AuthorizationService {
    typealias Handler = (_ accessToken: String, _ refreshToken: String) -> Void

    func authorize(then handler: @escaping Handler)
}

Since both of the above token values are strings, we need to add labels (and most probably a comment) in an attempt to make it more clear which value is which. Again, this is easy to mess up, since the compiler won't give us any kind of warning if we accidentally misremember the order of the arguments when using the above API:

service.authorize { refreshToken, accessToken in
    // We're accidentially mixing up the two tokens by using the
    // wrong parameter order, but the compiler won't help us.
}

Let's instead get the type system to help us out. By declaring two simple structs we can create dedicated types for values representing each kind of token, making it impossible to accidentally pass a refresh token value as an access token:

struct AccessToken {
    let string: String
}

struct RefreshToken {
    let string: String
}

Not only does the above give us more type-safe code, it also makes our AuthorizationService a lot more self-documenting as well. There's now no mistaking which token is which, without adding any additional labels or comments:

protocol AuthorizationService {
    typealias Handler = (AccessToken, RefreshToken) -> Void

    func authorize(then handler: @escaping Handler)
}

Type aliases

It's not always practical to create new types for everything, and sometimes doing so can actually make our code more complex as well. However, we might still want to leverage the type system to make our code more self-documenting, and in such situations using type aliases can be a great option.

For example, let's say we have a type that we use to represent files in our app:

struct File {
    let name: String
    let size: Int
}

Looking at the above declaration, it's a bit unclear what size refers to. Are we talking bytes, kilobytes, megabytes? How about number of characters if we're dealing with text files? While we'll probably be able to figure out the answer by poking around in the implementation, if things are not obvious at the declaration level, that's usually a bad sign.

Based on some of the techniques we're already taken a look at, there's a few different approaches we could take:

All of the above solutions are valid, but perhaps the simplest solution is to use a typealias to make it clear that we're dealing with bytes as our unit of measurement:

struct File {
    typealias ByteCount = Int

    let name: String
    let size: ByteCount
}

However, using type aliases this way is something we could easily go overboard with - resulting in code that is the opposite of simple and easy to understand, like this:

struct User {
    typealias Name = String
    typealias Age = Int
    typealias CityName = String

    let name: Name
    let age: Age
    let cityName: CityName
}

Like always, balance is key. When used sparingly (especially for things like units of measurement), type aliases can be a pretty nice solution. Here's another example in which we use type aliases to define our units for gravity and wind strength for a level in a game, while still using primitives for properties like name and index:

class Level {
    typealias Newtons = CGFloat
    typealias MetersPerSecond = CGFloat

    let index: Int
    let name: String
    var gravity: Newtons
    var windStrength: MetersPerSecond
}

Conclusion

While documentation and comments certainly have their place - especially for showing the use cases for a certain API - there's a lot of things we can do to make our code easier to understand without writing any form of instructions. That way, when we actually do write documentation, we can focus on the bigger picture instead of tying our text too much to implementation details that are always bound to change.

Especially in statically typed languages like Swift, using the type system to write code that is more robust and also more clear, is usually a great option. That way, the compiler can help us enforce correctness, and our code becomes easier to work with thanks to better auto completion and more clearly defined APIs.

What do you think? What are your favorite techniques for making code more self-documenting? Let me know - along with any questions, comments or feedback you might have - on Twitter @johnsundell.

Thanks for reading! 🚀