Swiftology

Monthly articles on advanced Swift topics, functional programming, and software design.

written byAlex Ozun

Greater type safety with Structural Typing in Swift

In this article I will show you how to achieve a strong compile-time guarantee that your code is correct. I will demonstrate how the internal structure of your types can hold unbreakable rules within them, known as invariants. Through real-world examples, I'll try to convince you to view the structure of your types through the lens of type safety, ultimately levelling up your Swift programming skills.

Nominal Typing

Swift has a nominal type system. With nominal typing you explicitly define types with names, and two types with distinct names are directly incompatible, even if they appear to be identical otherwise.

For example, Cat and Dog are two distinct types, despite having identical structure:

struct Cat {
  let name: String
  let age: Int
}

struct Dog {
  let name: String
  let age: Int
}

func pet(_ cat: Cat) { /**/ }

pet(Cat(name: "Gustav", age: 7)) // ✅ fine
pet(Dog(name: "Beethoven", age: 10)) // ❌ type mismatch

Swift’s nominal typing enables strong type safety, making it impossible to accidentally mix values of nominally incompatible types.

Perhaps the most idiosyncratic example of nominal typing encountered in Swift are phantom types. It’s a type safety technique for pulling new types out of thin air, merely by placing a marker type parameter into a generic wrapper:

struct ID<PhantomType> {
  let id: String
}

func pet(_ id: ID<Cat>) { /**/ }

let catID: ID<Cat> = ID(id: "123")
let dogID: ID<Dog> = ID(id: "123")

pet(catID) // ✅ fine
pet(dogID) // ❌ type mismatch

You can see in this example how func pet(_ id: ID<Cat>) provides a strong guarantee that cat IDs won't be mixed with dog IDs, thanks to distinct type names. And the fact that only Cat and Dog type names, and not values, were passed into the ID initializer makes them phantom types.

As a Swift programmer, you probably feel at home with nominal typing, but how can we use structural typing for stronger type safety in Swift?

Structural Typing

Even though Swift doesn’t offer direct 1 support for structural typing, there are valuable lessons that Swift programmers can learn from type safety best practices employed by structural typing practitioners.

In structural typing, types don’t necessarily have explicit names. Instead, their identity is defined by fields and methods.

Most Swift programmers will be familiar with JSON, which is a part of JavaScript’s structural type system. Here’s a JSON schema where an object type is defined by two fields:

{
  "name": string,
  "age": int
}

And here are "cat" and "dog" objects that implicitly belong to this same type:

var json = {
  "cat": {
    "name": "Gustav",
    "age": 7
  },
  "dog": {
    "name": "Beethoven",
    "age": 10
  }
}

A significant advantage of structural typing lies in its flexibility for data destructuring. For instance, in JavaScript, there's no need to predefine types before drilling into their data; you can directly access and manipulate it:

console.log(json.cat.name) // "Gustav"

If we decode this JSON object into Swift we can verify that cat and dog indeed belong to the same type:

struct JSONObject: Codable {
  let cat: NameAndAge
  let dog: NameAndAge
}

struct NameAndAge: Codable {
  let name: String
  let age: Int
}

At first glance it appears that structural typing completely lacks type safety and is a huge step back compared to nominal typing. After all, we’ve just managed to mix cats and dogs!

But structural typing teaches us a different, and in some ways more powerful, approach to type safety.

With structural typing we can’t rely on type names to prevent us from making mistakes. Instead, we must think hard about how a type’s structure can help us in ensuring correctness.

A type’s structure allows us to embed invariants within it. Type invariants are conditions that are guaranteed to always hold true for a given type, and they place constraints on operations that can be performed on values of that type.

Let’s look at a few examples where structural typing, when applied in a statically typed language like Swift, significantly elevates type safety.

📝 Example: Non-Empty Array

A classic example of how a type invariant can be embedded into type’s structure is the non-empty array.

struct NonEmptyArray<Element> {
  var head: Element //invariant
  var tail: [Element]

  var all: [Element] {
    CollectionOfOne(head) + tail
  }
}
non-empty

Note the non-optional head element. It’s the invariant that guarantees that the array will always have at least one element, even if tail is empty.

NonEmptyArray<Int>(head: 42, tail: [])

📝 Example: Zipper

Another great example of structural invariant can be found in a collection type known in Haskell and Elm as Zipper.

Suppose we’re building a multi-step questionnaire where the user is allowed to navigate backwards and forwards between questions, so we need to track their current position.

questions

A naïve way to model this would be something like this:

struct Questionnaire {
  let questions: [String]
  var position: Int // index-based
}

Questionnaire(
  questions: ["Question 1", "Question 2", "Question 3"],
  position: 0
)

But this type lacks structural type safety, allowing inconsistent states. For example, we can have no questions at all with the position pointing at the first question:

Questionnaire(
  questions: [],
  position: 0 // ❌ there's no first question
)

To fix this inconsistency we can take a page from the non-empty array’s book, and split the questions into two parts:

struct Questionnaire { 
  let firstQuestion: String // non-empty invariant
  let otherQuestions: [String]
  var position: Int
}

Questionnaire(
  firstQuestion: "Question 1",
  otherQuestions: [],
  position: 0  // ✅ fine
)

This is better, but we still permit an inconsistency where position doesn’t point at any of the questions on the list:

Questionnaire(
  firstQuestion: "Question 1",
  otherQuestions: ["Question 2", "Question 3"],
  position: 42 // ❌ not on the list
)

The only way to eliminate this inconsistency at structural level is to make the current question both the invariant of non-emptiness and the position tracker.

Let's see how Zipper can solve this challenge.

Zipper allows to keep track of a current position when traversing a collection, in a type-safe way.

struct Zipper<Element> {
  var previous: [Element]
  var current: Element // invariant
  var next: [Element]
}

The name Zipper refers to a physical zipper on a normal pair of trousers, where the current element zips through all elements, from previous to next.

zipper
var questionnaire = Zipper(
  previous: [],
  current: "Question 1",
  next: ["Question 2", "Question 3"]
)

questionnaire.goForward()
// previous: ["Question 1"]
// current: "Question 2" 
// next: ["Question 3"]

questionnaire.goForward()
// previous: ["Question 1", "Question 2"]
// current: "Question 3" 
// next:[]

questionnaire.goBackwards()
// previous: ["Question 1"]
// current: "Question 2" 
// next: ["Question 3"]


questionnaire.goBackwards()
// previous: []
// current: "Question 1" 
// next: ["Question 2", "Question 3"]

Zipper’s structure embeds the following invariants:

  1. There must be at least one element.
  2. If there’s only one element, it must be the current element.
  3. current element must be a part of all elements.

Wait, isn’t this just about a good API design?

At this point you might be thinking that what I’ve been presenting as lessons from structural typing is just a good API design. Indeed, a well-crafted API helps to avoid state inconsistencies and restrict invalid operations on values. But API design is limited to a type’s public interface, whereas in languages like Swift, type invariants can be embedded at private and internal levels and be completely hidden from users.

📝 Example: UUID's hidden structure

Let’s say we want to implement our own UUID type. UUID is a 16-bytes long sequence of hex digits, when formatted as a string it looks like this: "123e4567-e89b-12d3-a456-426614174000".

At the API level we only care about string representation, so it would look like this:

public struct UUID {
  public var uuidString: String { get }
  public init()
  public init?(_ uuidString: String)
}

At this point our UUID type looks like a simple wrapper around a raw String value. And thanks to nominal typing we eliminate the risk of accidentally mixing UUID and String values, which is great!

But we can further improve type safety by encoding the 16-byte invariant into the type’s structure. We can do this by internally representing UUID as 16 individual bytes instead of a single arbitrary String. We can define a byte as UInt8, and 16 bytes as a tuple of 16 UInt8s:

public struct UUID {
  private typealias Bytes16 = (
    UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
    UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8
  )
  private let uuidBytes: Bytes16 // invariant

  public init() {
    // create a random UUID, byte by byte
    uuidBytes = (byte1, byte2, ..., byte16)
  }
}

With this, the integrity of our type will never be subverted.

The public String-based interface can now be derived from the internal bytes representation:

extension UUID {
  public var uuidString: String {
    return toString(from: uuidBytes)
  }
  
  public init?(_ uuidString: String) {
    // attempt to parse 16 bytes from a string 
    uuidBytes = (...)
  }

  private func toString(from bytes: Bytes16) {
    // string encoding and formatting…
  }
}

In fact, this is exactly how the underlying C-language uuid.h is defined, and how Swift wrapper exposes bytes representation for interoperability.

Conclusions

Type safety is a multi-level concept. Great type safety takes advantage of as many elements of type metadata as possible: a type’s name, properties, methods, relations to other types, the set of possible values, etc. By combining type safety techniques from nominal and structural typing we can achieve strongest guarantees of a program correctness.


Footnotes

  1. There are multiple ways Swift can access actual structural typing, but they all require putting in some legwork:

    • By delegating operations that require structural typing to JavaScript via JSContext.
    • By delegating operations that require structural typing to Python via PythonKit.
    • By annotating Swift types that you own with @dynamicMemberLookup and @dynamicCallable. Your Swift types will need to be backed by some unstructured data like JSON or Dictionary. See examples in the original proposal .
    • By using Codable and JSONSerialization to jump between your types and their JSON dictionary representation when you need to dynamically manipulate data.
    • By using Mirror reflection to introspect type’s structure at runtime. Mirror is pretty limited, though, especially for enums. You might want to consider libraries like Runtime that reconstruct a lot of missing information from memory layouts.
    • By doing metaprogramming with SwiftSyntax. You can write Swift Macros that perform various operations based on type’s structure. For example, dynamically generate Mocks or Builders based on object’s methods and properties. At the time of writing this article, the objc.io team had started a new series that explores this avenue.
    • By using Objective-C runtime programming APIs to introspect and manipulate a type's structure, assuming the type is exposed to Obj-C runtime.
    ↩️