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
}
}
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.
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
.
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:
- There must be at least one element.
- If there’s only one element, it must be the
current
element. 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.
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 UInt8
s:
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
-
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
andJSONSerialization
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, theobjc.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.
- By delegating operations that require structural typing to JavaScript via