Objects at the Command Line #002 — A State Machine With No `switch`
Almost every state machine is born clean and dies as a nested conditional. Here’s the familiar shape — a turnstile in JavaScript, written the way most of us would reach for first:
function turnstile(state, event) {
switch (state) {
case "locked":
switch (event) {
case "coin": console.log("click -- unlocked"); return "unlocked";
case "push": console.log("BONK -- still locked"); return "locked";
}
break;
case "unlocked":
switch (event) {
case "coin": console.log("clink -- already open"); return "unlocked";
case "push": console.log("clack -- locked"); return "locked";
}
break;
}
}
let state = "locked";
for (const event of ["push", "coin", "push", "push", "coin", "coin"]) {
state = turnstile(state, event);
}
It works. It’s also a matrix: every state crossed with every event, and the transitions
buried inside nested switches. Add a maintenance state and you’re editing branches everywhere.
Here’s the same machine in GNU Smalltalk:
Object subclass: Locked [
coin [ Transcript showCr: 'click -- unlocked'. ^Unlocked new ]
push [ Transcript showCr: 'BONK -- still locked'. ^self ]
]
Object subclass: Unlocked [
coin [ Transcript showCr: 'clink -- already open'. ^self ]
push [ Transcript showCr: 'clack -- locked'. ^Locked new ]
]
| state events |
state := Locked new.
events := #(#push #coin #push #push #coin #coin).
events do: [:event | state := state perform: event ].
Run it (gst after.st) and you get the exact same trace.
What just happened to the matrix
It disappeared. There is no if comparing a state, and no switch comparing an event —
because each state is an object and each event is a message. Locked knows what
coin means to it; Unlocked knows what push means to it. The transition table is
just “which object answers which message,” and objects answering messages is the one thing
Smalltalk always does.
The line worth staring at
events do: [:event | state := state perform: event ].
events is a list of symbols — #coin, #push. perform: takes a symbol and sends it
as a message. So the input stream literally becomes a sequence of method calls. The thing
that would be a dispatch table in another language is, here, the language’s normal way of
doing anything.
The transferable lesson
You don’t need Smalltalk to use this. Whenever a conditional branches on “what kind of thing is this,” that thing is asking to become a polymorphic object. The Gang-of-Four State pattern can feel like ceremony in other languages; in a message-passing world it’s just the path of least resistance. New state = new class. The existing states never change — that’s the open/closed principle again, showing up as the natural grain of the code.
Honest caveat: for a two-state toggle, the if is perfectly fine — don’t build a class
hierarchy for a light switch. This pays off when the states are real and multiplying:
checkout flows, connection lifecycles, subscription status, game modes.
Every sample is executed against GNU Smalltalk 3.2.5 before it ships — this turnstile trace is real output. I write these in VS Code using my open-source GNU Smalltalk extension. How would you have modeled this — and what should Episode #003 tackle?