Skip to content

Instantly share code, notes, and snippets.

@rupertlssmith
Last active October 9, 2024 09:35
Show Gist options
  • Save rupertlssmith/88946c8d207d7ad64daf4360fef1ac42 to your computer and use it in GitHub Desktop.
Save rupertlssmith/88946c8d207d7ad64daf4360fef1ac42 to your computer and use it in GitHub Desktop.
Exploring State Machines with phantom types in Elm

This Gist explores the idea of using phantom types to encode the possible states that are allowed to make transitions into some other state in a state machine.

This also demonstrates how this can be used in a more real world setting where states in the machine may have addition data, and functions need to be mapped over that data or updates to it made, rather than just a pure state machine.

As the example runs it prints out the states, showing how the shape of the model varies as the state machine runs. This is the point of using the state machine; it only makes available fields in the model that need to exist in any given state. This removes the need for lots of fields in the model to by 'Maybe's, or to have lots of 'Bool' flags in the model to indicate when certain states are valid:

Loading (State {})
Ready (State { definition = { boardSize = 100 } })
InPlay (State { definition = { boardSize = 100 }, play = { score = 0, position = [] } })
GameOver (State { definition = { boardSize = 100 }, finalScore = 123 })
Ready (State { definition = { boardSize = 100 } })

The state machine exported by this module can only be manipulated in legal ways by code consuming the module. A constructor function is provided to create the initial state 'loading' but from there the 'toX' functions must be used to move through states without bypassing the rules of the state machine.

Note: The 'State' type is fully exposed in the the-sett/elm-state-machines package here: [ https://github.com/the-sett/elm-state-machines/blob/1.0.0/src/StateMachine.elm#L27 ]. This means that it is possible to create illegal states and use the exposed Game constructors to build them. The alternative would be to not expose the 'State' type, but that would require that each state machine define its own private version of it with 'map' and 'untag' functions. In order to cheat in this way, the consumer of the state machine would need to import the StateMachine module, and use it to build illegal states.

module GameState
exposing
( Game(..)
, GameDefinition
, PlayState
, loading
, updateGameDefinition
, updatePlayState
, updateScore
, toReady
, toReadyWithGameDefinition
, toInPlayWithPlayState
, toGameOver
)
import StateMachine exposing (..)
-- An Example model for a game of some kind.
type alias GameDefinition =
{ boardSize : Int
}
type alias PlayState =
{ score : Int
, position : List Int
}
-- The state definitions have enough typing information to enforce matching
-- states against legal state transitions, and against the available data model
-- in the state.
type Game
= Loading (State { ready : Allowed } {})
| Ready (State { inPlay : Allowed } { definition : GameDefinition })
| InPlay (State { gameOver : Allowed } { definition : GameDefinition, play : PlayState })
| GameOver (State { ready : Allowed } { definition : GameDefinition, finalScore : Int })
-- State constructors.
loading : Game
loading =
State {} |> Loading
ready : GameDefinition -> Game
ready definition =
State { definition = definition } |> Ready
inPlay : GameDefinition -> PlayState -> Game
inPlay definition play =
State { definition = definition, play = play } |> InPlay
gameOver : GameDefinition -> Int -> Game
gameOver definition score =
State { definition = definition, finalScore = score } |> GameOver
-- Update functions that can be applied when parts of the model are present.
mapDefinition : (a -> b) -> ({ m | definition : a } -> { m | definition : b })
mapDefinition func =
\model -> { model | definition = func model.definition }
mapPlay : (a -> b) -> ({ m | play : a } -> { m | play : b })
mapPlay func =
\model -> { model | play = func model.play }
updateGameDefinition :
(GameDefinition -> GameDefinition)
-> State p { m | definition : GameDefinition }
-> State p { m | definition : GameDefinition }
updateGameDefinition func state =
map (mapDefinition func) state
updatePlayState :
(PlayState -> PlayState)
-> State p { m | play : PlayState }
-> State p { m | play : PlayState }
updatePlayState func state =
map (mapPlay func) state
updateScore : Int -> PlayState -> PlayState
updateScore score play =
{ play | score = score }
-- State transition functions that can be applied only to states that are permitted
-- to make a transition.
toReady : State { a | ready : Allowed } { m | definition : GameDefinition } -> Game
toReady (State model) =
ready model.definition
toReadyWithGameDefinition : GameDefinition -> State { a | ready : Allowed } m -> Game
toReadyWithGameDefinition definition game =
ready definition
toInPlayWithPlayState : PlayState -> State { a | inPlay : Allowed } { m | definition : GameDefinition } -> Game
toInPlayWithPlayState play (State model) =
inPlay model.definition play
toGameOver : State { a | gameOver : Allowed } { m | definition : GameDefinition, play : PlayState } -> Game
toGameOver (State model) =
gameOver model.definition model.play.score
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment