fbpx
Replacing Optionals with Enums to Manage State
Share on linkedin
Share on twitter
Share on facebook
Share on email
Share on print

 

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?

The Problem with Optionals

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?

Using Enums to Manage State

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.

Using Enums to Force State Integrity

 

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: scheduledstartedfinished, 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:

 

  1. None of the state changing methods take into account the current state of the game.
  2. We have a number of Bool flags that can conflict with each other (isScheduled, isCancelled, etc.).
  3. We have properties that might be populated or 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.

The Verdict on Maintaining State

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! 

 

New call-to-action