Typestate - the new Design Pattern in Swift 5.9
This article will teach you 3 ideas:
1. Typestate Design Pattern.
2. The power of generic constraints.
3. Swift 5.9 new Noncopyable types and consuming functions.
What is a Typestate design pattern?
Typestate is a powerful design pattern that emerged in languages with advanced type systems and strict memory ownership models, notably Rust. It is now available to Swift programmers with the introduction of Noncopyable types in Swift 5.9.
Typestate brings the concept of a State Machine into the type system. In this pattern, the state of an object is encoded in its type, and transitions between states are reflected in the type system.
Crucially, Typestate helps catch serious logic mistakes at compile time rather than runtime. This makes it great for designing mission-critical systems, especially where human safety is involved (see the Tesla car example).
Like with most design patterns, the best way to understand it is by examining some examples.
📝 Example: Turnstile
Let's design a coin-operated turnstile with a coin counter.
The turnstile is a simple state machine with two states — Locked
and Unlocked
— and two operations: Insert Coin
and Push
.
Let's first look at a naïve implementation and then rewrite it using the Typestate pattern.
Turnstile - Naïve Design
The simplest way of modelling the state is with an enum. We start in the locked
state with zero coins
:
struct Turnstile {
enum State {
case locked
case unlocked
}
private var state: State = .locked
private(set) var coins: Int = 0
}
The insertCoin
operation increments coins
count and transitions the turnstile to the unlocked
state:
struct Turnstile {
private var state: State = .locked
private(set) var coins: Int = 0
mutating func insertCoin() {
coins += 1
state = .unlocked
}
}
But since we can't prevent users from calling insertCoin
when the turnstile is already unlocked, we need to defensively check that the turnstile is locked to avoid double charging the user:
struct Turnstile {
private var state: State = .locked
private(set) var coins: Int = 0
mutating func insertCoin() {
guard state == .locked else { return }
coins += 1
state = .unlocked
}
}
The push
operation simply transitions the turnstile into the locked
state:
struct Turnstile {
private var state: State = .locked
private(set) var coins: Int = 0
mutating func insertCoin() { ... }
mutating func push() {
state = .locked
}
}
Let's give this implementation a try:
var turnstile = Turnstile() // locked
turnstile.insertCoin() // unlocked
turnstile.coins // 1
turnstile.insertCoin() // does nothing
turnstile.coins // still 1
turnstile.push() // locked
turnstile.push() // still locked
The turnstile behaves as expected and seems to be resilient to our attempts at breaking it by performing illegal operations. This could be a viable solution.
But what would happen if someone later removed the guard check?
mutating func insertCoin() {
//guard state == .locked else { return }
coins += 1
state = .unlocked
}
The code would still compile and run like nothing had happened, but there would be a critical bug in the system:
var turnstile = Turnstile() // locked
turnstile.insertCoin() // unlocked
turnstile.coins // 1
turnstile.insertCoin() // illegal operation
turnstile.coins // 2 ⚠️ We've double charged the user!
Let's hope we had a robust and well-maintained suite of tests that caught this critical bug before it had reached production!
Remember! Tests can be disabled, linter rules can be suppressed, pull requests with failing checks can be force merged by admins. The only real guarantee that undesired change doesn't reach production is when the code fails to compile!
So wouldn't it be better if the code failed to compile when illegal operations were called?
This is the result we'd like to see:
var turnstile = Turnstile() // locked
turnstile.insertCoin() // unlocked
turnstile.insertCoin() // ❌ ERROR: Can't insert coins when unlocked
turnstile.push() // locked
turnstile.push() // ❌ ERROR: Can't push when locked
Turns out it's possible to design our Turnstile to achieve exactly that. This is where the Typestate design pattern comes into play.
Turnstile - Typestate Design
The first step is to encode the turnstile's states into the type system:
enum Locked {}
enum Unlocked {}
See how the states are no longer values of a State
type, they are types in their own right! We define them as enums with no cases because they are not meant to be initialised, they serve as marker types.
The next step is to make the Turnstile
type generic over the State
:
struct Turnstile<State> {
// private var state: State
private(set) var coins: Int
}
Notice that we no longer need the state
property, because the state is now a part of the type!
let locked: Turnstile<Locked>
let unlocked: Turnstile<Unlocked>
We've lifted our state logic from runtime values to compile-time types.
With this in place, let's redefine our insertCoin
and push
operations. But this time we can restrict them to corresponding states using generic constraints:
struct Turnstile<State> {
private(set) var coins: Int
}
extension Turnstile where State == Locked {
func insertCoin() -> Turnstile<Unlocked> {
//guard state == .locked else { return }
return Turnstile<Unlocked>(coins: coins + 1)
}
}
We transition to the next state by returning a new turnstile of a corresponding type with incremented coins
. Notice that we no longer need that brittle runtime guard check. We now have the compile-time guarantee that insertCoin
operation can only be performed on Turnstile<Locked>
.
This is incredibly powerful and I'd like you to fully appreciate this fact.
Similarly, let's restrict push
to the Unlocked
state:
struct Turnstile<State> {
private(set) var coins: Int
}
extension Turnstile where State == Locked {
func insertCoin() -> Turnstile<Unlocked> {
return Turnstile<Unlocked>(coins: coins + 1)
}
}
extension Turnstile where State == Unlocked {
func push() -> Turnstile<Locked> {
return Turnstile<Locked>(coins: coins)
}
}
Finally, let's make init
on the generic type private
and only expose init
for the Locked
state:
struct Turnstile<State> {
private(set) var coins: Int
private init(coins: Int) {
self.coins = coins
}
}
extension Turnstile where State == Locked {
init() {
self.init(coins: 0)
}
func insertCoin() -> Turnstile<Unlocked> {
Turnstile<Unlocked>(coins: coins + 1)
}
}
extension Turnstile where State == Unlocked {
func push() -> Turnstile<Locked> {
Turnstile<Locked>(coins: coins)
}
}
Now, it's only possible to initialise the Turnstile<Locked>
. And the only way to produce Turnstile<Unlocked>
is by inserting a coin into a locked turnstile.
Let's give this new API a try:
var locked = Turnstile<Locked>()
var unlocked = locked.insertCoin()
unlocked.coins // 1
unlocked.insertCoin() // ❌ ERROR: Can't insert coin into an unlocked turnstile
locked = unlocked.push()
locked.push() // ❌ ERROR: Can't push a locked turnstile
We've achieved the desired behaviour where illegal operations can't be performed at all, raising the compiler errors!
Granted, we now have to juggle between two variables, one per state. This is unfortunate and we'll look into ways of mitigating this shortly.
But first we need to address another issue. The fact that we now have two state variables means that we can break the system by reusing one of the variables to repeat illegal operations:
var locked = Turnstile<Locked>()
var unlocked = locked.insertCoin()
unlocked = locked.insertCoin() // ⚠️ Reusing locked
// This shouldn't be allowed since we've
// already transitioned to the unlocked state
unlocked.coins // 2 ⚠️ We've double charged the user!
This problem is known as aliasing.
Intuitively, we should be able to address this problem by declaring Turnstile
type as a class
instead of a struct
, thus replacing copy semantics with reference semantics. This way, instead of creating new turnstile copies each time we transition to a new state, new variables would keep pointing at the same turnstile instance and observe its up-to-date state. This wouldn't work, though.
Typestate pattern requires each state to be represented by a distinct concrete type. We can't have references of different types pointing at the same object in memory, and some form of copying would have to occur anyway, bringing us back to the original problem. On top of that, reference semantics would expose us to all its challenges, such as dealing with race conditions in a concurrent environment. To build a robust system, we'd have to jump from struct
all the way to actor
and perform state transitions asynchronously.
But even if we could make references work, it's not what we want from the state value lifecycle standpoint. What we really want is to limit the lifetime of a transient state to the scope where it was created, and prevent it from being reused altogether.
This is where Swift 5.9's new strict memory ownership model comes into play. It's the second essential part of the Typestate pattern.
Noncopyable State
Swift 5.9 has introduced Noncopyable types, also known as "move-only" types. A struct
or an enum
can be marked as ~Copyable
, informing the compiler that its values cannot be copied. Functions that take arguments of such types have to explicitly declare how they use them. That's where the new consuming
and borrowing
keywords are used. You can read all about them in the original evolution proposal, but in the context of Typestate pattern we only care about consuming
functions.
When you pass a noncopyable value into a consuming
function, you can't use that value again below the function call.
Here's an example:
struct File: ~Copyable { ... }
func close(_ file: consuming File) { ... }
let file = File()
close(file)
file.doSomething() // ❌ ERROR: Can't use file after it was consumed by close
We can use consuming
functions to prevent our state variables from being reused.
First, let's mark Turnstile
as Noncopyable:
struct Turnstile<State>: ~Copyable {
...
}
Next, we mark all functions that transition the state as consuming
:
struct Turnstile<State>: ~Copyable {
...
}
extension Turnstile where State == Locked {
consuming func insertCoin() -> Turnstile<Unlocked> {
Turnstile<Unlocked>(coins: coins + 1)
}
}
extension Turnstile where State == Unlocked {
consuming func push() -> Turnstile<Locked> {
Turnstile<Locked>(coins: coins)
}
}
Notice that not only function arguments can be marked as consuming
but the function itself, meaning that it consumes self
. Much like mutating
functions mutate self
.
Let's revisit the earlier problematic scenario:
var locked = Turnstile<Locked>()
var unlocked = locked.insertCoin()
unlocked = locked.insertCoin() // ❌ ERROR: locked was consumed by the first insertCoin call
unlocked.coins // 1
This is perfect! As soon as we've transitioned to unlocked
state, the locked
variable's lifetime was ended, preventing its reuse.
We can still assign new values to these variables, which is perfectly legal as the turnstile's state changes over time:
var locked = Turnstile<Locked>()
var unlocked = locked.insertCoin()
// assigning new locked value
locked = unlocked.push()
unlocked = locked.insertCoin() // assigning next state
unlocked.coins // 2
We can also chain multiple operations, with the correct order enforced:
var turnstile = Turnstile<Locked>()
.insertCoin()
.push()
.insertCoin()
.push()
turnstile.coins // 2
turnstile
.insertCoin()
.insertCoin() // ❌ ERROR: can't insertCoin again
.push()
.push() // ❌ ERROR: can't push again
Addressing the issue of multiple state variables
You saw how Typestate requires distinct types to represent different states. This created the inconvenience where we had to juggle between two separate variables: locked
and unlocked
. This isn't a problem in scenarios where we just want to perform a series of operations in a strict order, once. For example, when reading from a file:
func readFromFile() -> String {
let closedFile = File<Closed>(path: "file.txt")
let openFile: File<Open> = closedFile.open()
let text = openFile.read() // can only read when File<Open>
_ = openFile.close()
return text
}
But there are scenarios where we need to hold onto a stateful object for a long time to perform operations continuously. Ideally, we'd want a single property to hold different states. To achieve this, we need to bring all states under a single type.
We can achieve this with an enum with associated values:
enum TurnstileState: ~Copyable {
case locked(Turnstile<Locked>)
case unlocked(Turnstile<Unlocked>)
}
We must mark this enum as ~Copyable
, which is required when associated values are also noncopyable.
TurnstileState
can expose APIs of all underlying states:
enum TurnstileState: ~Copyable {
case locked(Turnstile<Locked>)
case unlocked(Turnstile<Unlocked>)
mutating func insertCoin() { /* */ }
mutating func push() { /* */ }
func coins() -> Int { /* */ }
}
We can now interact with turnstile states through a single variable with a combined interface:
var turnstile = TurnstileState.locked(Turnstile<Locked>())
turnstile.insertCoin() // unlocked
turnstile.insertCoin() // does nothing
turnstile.push() // locked
turnstile.push() // does nothing
turnstile.coins() // 1
At this point you might be thinking that we've returned to the naïve design. But it's not true.
Remember, the main problem with the naïve design was not the public interface that allowed calling insertCoint
multiple times in a row. The problem was the internal implementation of insertCoin
that relied on the brittle runtime guard check to avoid double charging the user. With the Typestate design, this type of internal state corruption is impossible.
It's OK to have a permissive public interface as long as the internal implementation is bulletproof and the system's integrity is backed up by the compiler.
Let's see how to actually implement methods on this combined interface. Obviously, we need to switch on self
first:
enum TurnstileState: ~Copyable {
...
mutating func insertCoin() {
switch consume self {
case .locked: ...
case .unlocked: ...
}
}
}
When switching, we must consume self
, which is required for noncopyable enums.
Inside the locked
case we get access to Turnstile<Locked>
value and can perform insertCoin
on it.
enum TurnstileState: ~Copyable {
case locked(Turnstile<Locked>)
case unlocked(Turnstile<Unlocked>)
mutating func insertCoin() {
switch consume self {
case let .locked(locked):
let unlocked = locked.insertCoin()
case .unlocked:
break
}
}
}
As you can see, internally, we still get the full type-level access protection for operations.
Since we've just consumed self
, its lifetime has ended outside the switch
scope. We'll get this error:
enum TurnstileState: ~Copyable {
...
mutating func insertCoin() {
switch consume self {
case let .locked(locked):
let unlocked = locked.insertCoin()
case .unlocked:
break
}
// ❌ ERROR: Missing reinitialization of inout parameter 'self' after consume
}
}
This is a fantastic error to have! It gives us the compiler's support for correct state transitions. After we switch on the current state, we must assign the next state to self
, and the compiler will be there to catch our mistakes!
Typestate operations already return the next state, so the code almost writes itself:
enum TurnstileState: ~Copyable {
...
mutating func insertCoin() {
switch consume self {
case let .locked(locked):
let unlocked = locked.insertCoin()
self = .unlocked(unlocked)
case let .unlocked(unlocked):
self = .unlocked(unlocked)
}
}
}
For the locked
case, insertCoin
produces the next state, which we assign to self
. For the unlocked
case, which is a no-op, we just plug the same state back into self
.
This is absolutely amazing! It closes the compiler's feedback loop on our state machine transitions logic!
Similarly, we implement the push()
method:
enum TurnstileState: ~Copyable {
...
mutating func insertCoin() { ... }
mutating func push() {
switch consume self {
case let .locked(locked):
self = .locked(locked)
case let .unlocked(unlocked):
let locked = unlocked.push()
self = .locked(locked)
}
}
}
This concludes the Turnstile Typestate design. We've achieve a bulletproof internal implementation and provided a user-friendly public interface. You can explore the complete code listing in this GitHub Gist.
Swift 5.9 limitations which can make life harder in certain use cases
Swift 5.9 is the first version that saw Noncopyable types. As such, there's still a number of limitations on how Noncopyable types can be used. For example, they can't conform to any protocols. They also can't be used as parameters for generic types, such as Optional
or Result
. This means that if you have a transition that can fail, you can't just return an optional state. This won't compile:
// ❌ ERROR: Noncopyable type 'Turnstile<Unlocked>'
// cannot be used with generic type 'Optional<Wrapped>' yet
func maybeUnlock() -> Turnstile<Unlocked>? {...}
But I don't recommend modelling non-deterministic transitions this way anyway. With Typestate, we must always return one of the known states. So we should define custom enums to represent the bifurcation of state:
extension Turnstile where State == Locked {
enum TransitionResult: ~Copyable {
case locked(Turnstile<Locked>)
case unlocked(Turnstile<Unlocked>)
}
func maybeUnlock() -> TransitionResult {
if #condition# {
return .unlocked(…)
} else {
return .locked(…)
}
}
}
There are Apple-recommended workarounds for other limitations, and they will be lifted in future versions of Swift. But it's worth noting that currently they can make life much harder if you need to use Typestate with a lot of non-deterministic transitions.
Conclusion
Typestate is a powerful design pattern that brings great type and memory safety to your programs. It can drastically reduce the possibility of critical bugs and undefined behaviours by catching them at compile time. It can also reduce the reliance on inherently skippable quality control measures, such as tests, linters, code reviews, etc.
To decide if Typestate is a good choice for your use case, see if ANY of these apply:
- Your program behaves like a state machine. You can identify distinct states and transitions between them.
- Your program needs to enforce a strict order of operations, where out-of-order operations can lead to bugs or undefined behaviour.
- Your program manages resources that have open/use/close semantics. Typical examples: files, connections, streams, audio/video sessions, etc. Resources that can't be used before they are acquired, and that must be relinquished after use.
- Your program manages mutually exclusive systems. See the Tesla car example below, where the gas pedal either accelerates the real car or the video game car, depending on the state.
📝 Bonus Example: Playing Video Games on an Electric Vehicle
If you're hungry for more examples, let's raise the stakes and look at the car control system upon which a human life depends.
Tesla cars are known to allow playing video games, particularly racing games where the player uses the real steering wheel and pedals as game controllers.
Obviously, for safety reasons this is only allowed when a car is parked. So there must to be a robust software in place that prevents the car from entering the gaming mode and potentially giving up control over steering and pedalling to a video game while the car is on the move. You can explore this example in this GitHub Gist.