gala-state

March 1, 2026 · View on GitHub

State machines that the compiler understands.

GALA's sealed types and pattern matching turn state machines from a design pattern into a language feature. Define your states, define your events, and the compiler guarantees every transition is handled.

sealed type OrderState {
    case Pending()
    case Confirmed(ConfirmedBy string)
    case Shipped(TrackingId string)
    case Delivered()
    case Cancelled(Reason string)
}

sealed type OrderEvent {
    case Confirm(Approver string)
    case Ship(Tracking string)
    case Deliver()
    case Cancel(CancelReason string)
}

func transition(state OrderState, event OrderEvent) Either[string, OrderState] {
    return state match {
        case Pending() => event match {
            case Confirm(by) => Right[string, OrderState](Confirmed(by))
            case Cancel(reason) => Right[string, OrderState](Cancelled(reason))
            case _ => Left[string, OrderState]("pending orders can only be confirmed or cancelled")
        }
        case Confirmed(_) => event match {
            case Ship(tracking) => Right[string, OrderState](Shipped(tracking))
            case Cancel(reason) => Right[string, OrderState](Cancelled(reason))
            case _ => Left[string, OrderState]("confirmed orders can only be shipped or cancelled")
        }
        // ...
    }
}

No enums. No flags. No default: that silently swallows new states. Just types.


GALA vs Go

FeatureGALAGo
Statessealed type — each variant carries its own dataiota constants — data lives in a separate struct
Transitionsmatch expression — compiler checks every caseswitchdefault: silently swallows new variants
ExhaustivenessCompile-time error if you miss a caseNothing — bugs hide until runtime
Error handlingEither[Error, State] — typed, pattern-matchable(State, error) — caller can ignore the error
State dataEach variant carries exactly its fieldsFlat struct with all fields present in every state
Lines of code~60~120

Side-by-Side: Transition Function

GALA Go
func transition(state OrderState, event OrderEvent)
    Either[string, OrderState] {
  return state match {
    case Pending() => event match {
      case Confirm(by) =>
        Right[string, OrderState](Confirmed(by))
      case Cancel(reason) =>
        Right[string, OrderState](Cancelled(reason))
      case _ => Left[string, OrderState](
        "pending: can only confirm or cancel")
    }
    case Confirmed(_) => event match {
      case Ship(tracking) =>
        Right[string, OrderState](Shipped(tracking))
      // ...
    }
    // compiler enforces all states handled
  }
}
func transition(state OrderState, event OrderEvent) (OrderState, error) {
    switch state {
    case Pending:
        switch event {
        case Confirm:
            return Confirmed, nil
        case Cancel:
            return Cancelled, nil
        default:
            return state, errors.New(
                "pending: can only confirm or cancel")
        }
    case Confirmed:
        switch event {
        case Ship:
            return Shipped, nil
        // ...
        }
    default:
        // silently swallows new states
        return state, errors.New("unknown state")
    }
}

Notice what's missing from the Go version: Confirmed can't tell you who confirmed. Shipped can't carry a tracking ID. Cancelled can't carry a reason. In GALA, each state variant carries exactly the data it needs.


Running the Examples

Install GALA (instructions), then:

# E-commerce order FSM
cd examples/order && gala run

# Traffic light controller
cd examples/traffic_light && gala run

# Vending machine with coins
cd examples/vending_machine && gala run

# Go equivalent (for comparison)
cd go_equivalent/order && go run main.go

Examples

Order State Machine

The hero example: an e-commerce order that flows through Pending → Confirmed → Shipped → Delivered, with cancellation possible from Pending or Confirmed states.

=== Order State Machine ===

--- Happy Path ---
  Pending + Confirm(Alice) => Confirmed by Alice
  Confirmed by Alice + Ship(TRACK-42) => Shipped (tracking: TRACK-42)
  Shipped (tracking: TRACK-42) + Deliver => Delivered

--- Invalid Transition ---
  Pending + Ship(TRACK-99) => ERROR: pending orders can only be confirmed or cancelled
  Pending + Deliver => ERROR: pending orders can only be confirmed or cancelled

--- Cancellation Flow ---
  Pending + Confirm(Bob) => Confirmed by Bob
  Confirmed by Bob + Cancel(customer request) => Cancelled: customer request
  Cancelled: customer request + Ship(TRACK-00) => ERROR: cancelled orders cannot transition

Full source →

Traffic Light

The simplest state machine: three states, two signals. Shows how emergency overrides work.

=== Traffic Light State Machine ===

--- Normal Cycle ---
  Start: RED
  Timer: GREEN
  Timer: YELLOW
  Timer: RED

--- Emergency Override ---
  Start: GREEN
  Emergency: RED
  Timer: GREEN

Full source →

Vending Machine

The most complex example: accumulating state (coin insertion), conditional transitions (sufficient funds), and side effects.

=== Vending Machine State Machine ===

--- Buy a Soda (75c) ---
  Idle => Accepting (25c)
  Accepting (25c) => Accepting (50c)
  Accepting (50c) => Accepting (75c)
  Accepting (75c) => Dispensing Soda (change: 0c)
  Dispensing Soda (change: 0c) => Idle

--- Insufficient Funds ---
  Idle => Accepting (10c)
  Accepting (10c) => ERROR: need 40c more for Candy
  Accepting (10c) => Accepting (35c)
  Accepting (35c) => Accepting (60c)
  Accepting (60c) => Dispensing Candy (change: 10c)
  Dispensing Candy (change: 10c) => Idle

--- Return Coins ---
  Idle => Accepting (25c)
  Accepting (25c) => Accepting (30c)
    Returning 30c
  Accepting (30c) => Idle

--- Out of Stock ---
  Out of Stock => ERROR: machine is out of stock

Full source →


Why GALA for State Machines?

  • Sealed types ARE states. Each variant is a state, and each variant carries exactly the data that state needs — no flat structs with impossible field combinations.

  • Match IS the transition function. Pattern matching destructures state and event simultaneously, extracting data in the same expression that routes the transition.

  • Exhaustiveness IS safety. The compiler rejects code that doesn't handle every state/event combination. Add a new variant and every match in your codebase lights up with errors until you handle it.

  • Either IS error handling. Either[string, OrderState] makes invalid transitions a value, not an exception. The caller must pattern-match the result — they can't accidentally ignore an error.

  • Immutability IS the audit trail. val state = Pending() can't be silently mutated. State transitions produce new values, making the flow explicit and traceable.