What makes a good Swift API: naming, argument labels, defaults, ownership, thread documentation and misuse resistance?

15.5.1 · L5 · Interview

Predict first

A function is marked unavailable and renamed, and then called. What does the compiler do?

struct Old {
    @available(*, unavailable, renamed: "fetchUser(id:)")
    func getUser(_ id: Int) {}
    func fetchUser(id: Int) {}
}
Old().getUser(1)
  • It compiles and runs
  • It is an error that suggests the new name

Mental model. A good API is a well-labelled control panel: the names tell you what each switch does, dangerous switches are guarded, and the manual (the documentation) says what happens when you press them.

Swift’s API Design Guidelines start from clarity at the point of use: judge a name by how the call reads, not the declaration. Naming: omit needless words and include those that prevent ambiguity (remove(at: index), not remove(index)); name by role, not type; boolean properties read as assertions (isEmpty); mutating and non-mutating pairs follow verb versus -ed/-ing (sort() and sorted(), append(_:) and appending(_:)); factory methods start with make. Argument labels should form phrases (move(from: a, to: b)), drop the label when the first argument is the direct object (print(x)) and use _ sparingly. Defaults keep the common call short and push rare options out of the way (init(timeout: Duration = .seconds(30))), and a default argument is better than overloads that differ only by omission. Prefer value types for data, protocols with the smallest useful requirements, and generics over Any. Make misuse a compile error: required values as non-optional parameters, enums instead of strings, private(set) for state callers must not set, @discardableResult only when ignoring the result is normal, and distinct types for ids (UserID rather than Int).

Ownership and threading belong in the contract. State what the API does with arguments (does it retain this closure; is it escaping; is it called once or many times), which thread or actor it calls back on, and which thread you may call it from, using the type system where possible (@MainActor, Sendable, async) and documentation where not. Use consuming, borrowing and ~Copyable in performance-sensitive APIs to express ownership. Return values over out-parameters, throw rather than return sentinel values, and make failure modes explicit (see error modelling). Document every public symbol with /// comments: a one-line summary, then parameters, return, throws and a short example; DocC turns it into browsable documentation. Keep the API small and orthogonal: each public symbol is a long-term commitment. Test it by writing the usage code first, then showing the draft to someone unfamiliar and noting what they misread. Design for evolution: hide implementation types, avoid exposing internal collections, and use protocols and enums consciously (adding a case to a public enum can break callers’ exhaustive switches unless it is marked @frozen or handled with @unknown default).

An unavailable declaration cannot be called, and the renamed argument makes the diagnostic point to the replacement.

Common traps

  • Naming from the implementation instead of the call site. removeObject(atIndex:) is noisier than remove(at:).
  • Boolean parameters at the call site (load(true, false)). Use labels or enums so the call explains itself.

They might follow up with

  • What do sort() and sorted() signal? The first mutates in place; the second returns a new value.
  • Where should threading expectations live? In the types (@MainActor, Sendable, async) and in the documentation.

Related questions

  • 15.5.2 How do you evolve a public API without breaking callers: deprecation, renames, versioning and source versus…
  • 15.4.1 How do you model errors in Swift: throws, Result, optionals and typed throws, and how do you translate them…

How do you evolve a public API without breaking callers: deprecation… →