Using enumeration or enums to manage state is a useful option to make code less fragile and more robust by replacing state scenarios that shouldn’t be possible with solutions that make them impossible.
Hold up – how do you end up in a state that shouldn’t be possible?
Using optionals without adding the additional logic to handle every future state change can lead to states that shouldn’t be possible. Optionals are used in situations where a value may be absent. An optional represents two possible states: either there is value or there isn’t a value at all. Where optionals only represent two possible states, enums are able to specify and combine multiple “stateful” properties into a single state representation.
Let’s clarify using Apple’s example:
class UserAuthenticationController {
var isLoggedIn: Bool = false
var user: User?
}
In the case above, there are opportunities for this controller to enter an “illegal” state. For example, it’s possible for the controller to be in a “logged in” state while the user is nil
or non-existent, or vice versa, where the controller is in a “logged out” state when the user exists. Using an optional in this instance forces you to remember to nullify the user manually when entering a “logged out” state. To avoid this, we can capture the properties in a State
enum, and make it impossible to enter those “illegal” states:
class UserAuthenticationController {
enum State {
case idle
case loggedIn(user: User)
}
var state: State = .idle
}
Adding a State
enum immediately clarifies the controller states with a concrete state list the controller can take on.
Alright, but how exactly do you define an enum?
me @ myself
An enum defines a common type for a group of related values. Unlike c enums, which are represented by unique integer values for each case, Swift enums don’t require any primitive raw value. If a raw value is provided for each enumeration case, the value can be a string, a character, or a value of any integer or floating type.
Let me try this. I want to write an enum that filters hockey players for a fantasy draft into a list that matches each player to their position.
I’ll start by introducing my enum type and each enum value using the case
keyword:
enum PlayerPosition {
case leftwing
case rightwing
case center
case leftdefense
case rightdefense
case goalie
case benchplayers
}
I also want each position to match players I’ve chosen for my fantasy draft. I’ll make this happen by using a switch statement for each enumeration case.
func myTeam(for PlayerPosition: PlayerPosition){
switch PlayerPosition{
case .leftwing:
print ("Alex Ovechkin, Evander Kane" )
case .rightwing:
print ("Nikita Kucherov, Mitch Marner")
case .center:
print ("Evgeny Kuznetsov, Patrice Bergeron")
case .leftdefense:
print ("Radim Simek, Morgan Rielly" )
case .rightdefense:
print ("Kris Letang, Kasperi Kapanen")
case .goalie:
print ("Andrei Vasilevskiy, Martin Jones")
case .benchplayers:
print ("Auston Matthews, Joe Pavelski, Brad Marchand, Mark Scheifele, William Nylander")
}
}
Time to check with a professional. And the verdict is…
I’m not wrong, but I’m not right either (It seems I hear that a lot from developers).
me @ developers
In respect to enums, there’s nothing wrong with my first attempt; however, you’re typically not going to have hard-coded lists of data in an app. In a real application, data is likely to be coming from the network or some form of persistent storage.
Fine, so I’m not entirely right. Let’s explore how to use enums to manage state and run with this hockey game idea. For the next part of this article, we’ll examine an example of modifying state in a hockey game app, the problems that could possibly arise if State
isn’t handled properly, and how to improve the validity of transitions by implementing a State
enum. First, let’s set the foundation of our hockey game idea.
To start here are a few simple sketches of what game objects might look like:
protocol Player {}
protocol Team {
var players: [Player] { get }
}
Above we’ve defined protocols to represent Players and Teams. For this exercise, our Player
doesn’t need properties, while our Team
provides a read-only list of players.
struct Score: Equatable {
static let zero = Score(home: 0, away: 0)
var home: Int
var away: Int
/// Returns a new Score adding the supplied home and away values to this score.
func adding(home: Int, away: Int) -> Score {
return Score(home: self.home + home, away: self.away + away)
}
}
Next, we’ll want our app to keep track of game scores. Above, we’ve built a struct to represent the score of a game consisting of an integer value for each home and away team, and a function to return a new Score
by adding the supplied home and away values to the scoreboard.
class Game0 {
let home: Team
let away: Team
var score: Score?
init(home: Team, away: Team) {
self.home = home
self.away = away
}
}
Game0
above, represents the skeleton of a game with a few properties we might want in a game. It’s pretty straightforward: a game has two teams, and if a game is in play, there is a score. You’ll notice we have to declare the score
as an optional Score
. The score is defined as optional to allow us to distinguish between a game that hasn’t started yet and a game with a 0-0 score.
We can give our game more detail by adding different states. In Game1
, we’ll give a game a date once it’s been scheduled, a list of Player
stars once it’s over, and four different game states: scheduled
, started
, finished
, or cancelled
.
class Game1 {
let home: Team
let away: Team
var score: Score?
private(set) var date: Date? // nil if not scheduled yet
private(set) var stars: [Player]? // nil if game hasn't finished
private(set) var isScheduled: Bool = false
private(set) var isStarted: Bool = false
private(set) var isFinished: Bool = false
private(set) var isCancelled: Bool = false
init(home: Team, away: Team) {
self.home = home
self.away = away
}
}
Now, we’re going to add functions to change game states. In the next example, there are several problems that might arise by calling these functions in unexpected ways.
class Game2 {
let home: Team
let away: Team
var score: Score?
private(set) var date: Date? // nil if not scheduled yet
private(set) var stars: [Player]? // nil if game hasn't finished
private(set) var isScheduled: Bool = false
private(set) var isStarted: Bool = false
private(set) var isFinished: Bool = false
private(set) var isCancelled: Bool = false
init(home: Team, away: Team) {
self.home = home
self.away = away
}
/// Schedules the game for the specified date.
func schedule(date: Date) {
isScheduled = true
self.date = date
}
/// Starts the game
func start() {
isStarted = true
score = .zero
}
/// Ends the game
func end(stars: [Player]) {
isFinished = true
self.stars = stars
}
/// Cancels the game
func cancel() {
isCancelled = true
}
/// Adds points to the home score
func homeScored(_ points: Int) {
score?.home = (score?.home ?? 0) + points
}
/// Adds points to the away score
func awayScored(_ points: Int) {
score?.away = (score?.away ?? 0) + points
}
}
What happens if a canceled game is started? What happens if a started game is scheduled? Can isFinished
and isCancelled
be true simultaneously? Should they be able to? In Game3
, let’s build a State
enum to address the apparent issues we’ve identified in Game2
.
class Game3 {
/// The state of a game
enum State {
/// Game has not yet been scheduled
case tbd
/// Game has been scheduled for `date`
case scheduled(date: Date)
/// Game is in progress, has scheduled date, and current score
case started(date: Date, score: Score)
/// Game has been cancelled, date represents the previously scheduled
/// date, if any
case cancelled(date: Date?)
/// Game has ended, score represents final score, stars is the list of star players
case over(date: Date, score: Score, stars: [Player])
}
let home: Team
let away: Team
var state: State = .tbd // start out unscheduled
var score: Score? { return state.score }
var date: Date? { return state.date }
var stars: [Player]? { return state.stars }
var isScheduled: Bool { return state.isScheduled }
var isStarted: Bool { return state.isStarted }
var isFinished: Bool { return state.isFinished }
var isCancelled: Bool { return state.isCancelled }
init(home: Team, away: Team) {
self.home = home
self.away = away
}
func schedule(date: Date) {
state = state.schedule(date: date)
}
func start() {
state = state.start()
}
func end(stars: [Player]) {
state = state.end(stars: stars)
}
func cancel() {
state = state.cancel()
}
func homeScored(_ points: Int) {
state = state.scored(home: points, away: 0)
}
func awayScored(_ points: Int) {
state = state.scored(home: 0, away: points)
}
}
/// Provide functions for transitioning between game states.
private extension Game3.State {
var score: Score? {
switch self {
case .started(_, let score): return score
case .over(_, let score, _): return score
default: return nil
}
}
var date: Date? {
switch self {
case .scheduled(let date): return date
case .started(let date, _): return date
case .over(let date, _, _): return date
case .cancelled(let date): return date
default: return nil
}
}
var stars: [Player]? {
switch self {
case .over(_, _, let stars): return stars
default: return nil
}
}
var isScheduled: Bool {
switch self {
case .tbd, .cancelled: return false
default: return true
}
}
var isStarted: Bool {
switch self {
case .started, .over: return true
default: return false
}
}
var isFinished: Bool {
switch self {
case .over: return true
default: return false
}
}
var isCancelled: Bool {
switch self {
case .cancelled: return true
default: return false
}
}
func schedule(date: Date) -> Game3.State {
switch self {
case .tbd, .scheduled: return .scheduled(date: date)
default: return failTransition(for: #function)
}
}
func start() -> Game3.State {
switch self {
case .tbd: return .started(date: Date(), score: .zero)
case .scheduled(let date): return .started(date: date, score: .zero)
default: return failTransition(for: #function)
}
}
func scored(home: Int, away: Int) -> Game3.State {
switch self {
case .started(let date, let score): return .started(date: date, score: score.adding(home: home, away: away))
default: return failTransition(for: #function)
}
}
func end(stars: [Player]) -> Game3.State {
switch self {
case .started(let date, let score): return .over(date: date, score: score, stars: stars)
default: return failTransition(for: #function)
}
}
func cancel() -> Game3.State {
switch self {
case .tbd: return .cancelled(date: nil)
case .scheduled(let date): return .cancelled(date: date)
case .started(let date, _): return .cancelled(date: date)
default: return failTransition(for: #function)
}
}
private func failTransition(for action: String) -> Game3.State {
assertionFailure("Attempt to \(action) a game that is \(self)")
return self
}
}
In Game2
we added methods that change state (schedule, start, cancel, etc.). When we did this, we opened the door to several issues:
Bool
flags that can conflict with each other (isScheduled
, isCancelled
, etc.). nil
regardless of the state that the game should be in.
In Game3
, we implemented a State
enum. The state enum immediately creates safer code by creating a concrete list of states the game might be in. Properties that are only valid in particular states are no longer awkwardly detached from the game, rather they are associated values to the state they are relevant to.
With game properties captured inside our State
enum and a well-defined list of states a game can take on, the enum makes our state transitions safe. We have a well-defined list of actions that cause state transitions. These actions are schedule, start, end, cancel, scored
and they map onto the state changing methods added in Game2
.
Nothing is stopping you from spreading state across multiple variables. Make your life easier and avoid potential state issues – use enums.
I’d like to give big props to Andrew Patterson, who helped me learn about enumeration and write this article. Thank you, for all your help, patience, and insight: I couldn’t have written this without you. I can’t wait to work on our next project!