why elm in a systems language
so i've been building wayu, a TUI app in Odin, and at some point the state management got out of hand. i had flags scattered everywhere, views reaching into random parts of the model, input handling that mutated five things at once. classic stuff. i'd been through this before in javascript and the thing that always saved me was the elm architecture — TEA. model, update, view. that's it.
the question was whether i could pull it off in Odin. no closures. no generics in the traditional sense (odin has parametric polymorphism but it's not the same thing). no higher-order anything really. just procedures, structs, and unions.
turns out it maps surprisingly well.
the message type
in elm you have a Msg type that represents every possible thing that can happen in your app. in odin, tagged unions do this perfectly. you define each message variant as its own struct, then wrap them all in a union:
Key_Press :: struct {
key: Key,
}
Navigate :: struct {
target: View_Kind,
}
Search_Update :: struct {
query: string,
}
Modal_Open :: struct {
kind: Modal_Kind,
}
Modal_Close :: struct {}
Msg :: union {
Key_Press,
Navigate,
Search_Update,
Modal_Open,
Modal_Close,
}each variant carries exactly the data it needs. Modal_Close doesn't need anything so it's an empty struct. Navigate needs to know where to go. this is basically a sum type and odin handles it natively.
the update function
the update function takes a pointer to the model and a message, then does a #partial switch over the union. this is where all state transitions live. every single one. no exceptions.
update :: proc(model: ^Model, msg: Msg) {
switch m in msg {
case Key_Press:
handle_key(model, m)
case Navigate:
model.view = m.target
case Search_Update:
filter_items(model, m.query)
case Modal_Open:
model.modal = m.kind
case Modal_Close:
model.modal = nil
}
}in elm the update function returns a new model. here i'm mutating in place because this is a systems language and copying the entire model every frame would be wasteful. the spirit is the same though — all mutations go through this one chokepoint.
the #partial switch is nice because odin will warn you at compile time if you add a new message variant and forget to handle it. that's the kind of safety net you want.
the main loop
the main loop becomes dead simple. read input, turn it into a message, update, view. repeat.
for !model.should_quit {
key := read_key()
if msg, ok := key_to_msg(model, key); ok {
update(&model, msg)
}
view(&model)
}view is a pure-ish procedure that takes the model and draws the screen. it doesn't mutate anything. it just reads the model and renders. key_to_msg translates raw input into a message based on the current state — like if you're in search mode a keypress becomes Search_Update, otherwise it might be Navigate.
what i actually got out of this
the biggest win is debugging. when something goes wrong i know exactly where to look. the state is in the model. the transitions are in update. the rendering is in view. there's no spooky action at a distance where some callback three layers deep is flipping a flag i forgot about.
the second win is that adding features is mechanical. new feature? add a message variant, handle it in update, render it in view. the compiler tells you if you missed a case. i don't have to think about where state lives or who's allowed to touch it.
i was worried that without closures or real generics it would feel clunky. it doesn't. odin's tagged unions and switch statements are expressive enough that the pattern feels natural. if anything it's more explicit than the elm version because there's no magic — you can see every branch, every mutation, every render path just by reading the code top to bottom.
sometimes constraints are the feature.