the problem with three config types
so wayu manages three kinds of shell config: PATH entries, aliases, and environment variables. each one needs the same basic operations — add, remove, list, get. but the way you store and validate each type is completely different. PATH entries are an ordered array where duplicates matter and you need to check that directories actually exist. aliases are key-value pairs where you want to warn about shadowing system binaries. env vars are also key-value but with their own validation rules (don't let someone blow away HOME, etc).
in a language with interfaces or traits, you'd define a ConfigManager interface with methods for each operation, then implement it three times. classic strategy pattern. you get polymorphism, you get a single set of CLI commands that dispatch to the right implementation, and everything is clean.
but I'm writing this in Odin. no interfaces. no classes. no inheritance. no vtables hiding behind the scenes. so how do you get the same polymorphism?
function pointers. that's it.
the strategy struct
in Odin, procedures are first-class values. you can store them in structs, pass them around, call them through variables. so the strategy pattern becomes a struct full of function pointers:
Config_Strategy :: struct {
name: string,
add: proc(value: string) -> Error,
remove: proc(key: string) -> Error,
list: proc() -> []Config_Entry,
get: proc(key: string) -> (Config_Entry, bool),
validate: proc(value: string) -> bool,
}each config type gets its own instance of this struct with its own procedures wired in:
path_strategy := Config_Strategy{
name = "path",
add = path_add,
remove = path_remove,
list = path_list,
get = path_get,
validate = path_validate,
}
alias_strategy := Config_Strategy{
name = "alias",
add = alias_add,
remove = alias_remove,
list = alias_list,
get = alias_get,
validate = alias_validate,
}that's the whole pattern. no inheritance hierarchy. no interface declarations. just a struct with procedure fields and concrete implementations assigned at initialization.
one set of commands, three behaviors
the payoff is in the CLI and TUI code. instead of writing separate add/remove/list handlers for each config type, there's one generic handler that takes a strategy:
handle_add :: proc(strategy: Config_Strategy, value: string) -> Error {
if !strategy.validate(value) {
return .Invalid_Value
}
return strategy.add(value)
}
handle_list :: proc(strategy: Config_Strategy) {
entries := strategy.list()
for entry in entries {
fmt.printfln(" {} {}", entry.key, entry.value)
}
}the CLI parser figures out which config type the user is talking about, grabs the right strategy, and passes it to the generic handler. the TUI does the same thing — one list view component that works for all three types because it just calls through the strategy's list procedure.
when I added the TUI this really paid off. I didn't have to build three separate views for paths, aliases, and env vars. one view, parameterized by strategy. the rendering code doesn't know or care what kind of config it's displaying.
why this works better than you'd think
the thing I like about this approach is how explicit it is. in an OOP language, the dispatch through an interface is invisible — you call a method and the runtime figures out which implementation to use. here, you can see exactly what's happening. the strategy is a struct. the procedures are fields. you assigned them yourself. there's no magic.
it also means you can do things that interfaces make awkward. want a strategy where validate is optional? make it a Maybe(proc) or just check for nil. want to compose strategies? write a procedure that takes two strategies and returns a new one with merged behavior. want to swap a strategy at runtime? just reassign the field. it's all just data.
the downside is that Odin won't check at compile time that you've implemented all the required procedures. if you forget to set remove on a new strategy, you'll get a nil procedure call at runtime. in practice this hasn't bitten me because I set up all the strategies in one place during init and the tests cover each operation. but it's a real tradeoff compared to interfaces that enforce completeness.
the pattern generalizes
I've started using this same approach for other things in wayu. the TUI views each have a View_Strategy with procedures for render, handle_input, and get_status_text. the backup system has strategies for different storage backends. once you see it, you see it everywhere — anywhere you'd reach for an interface in another language, a struct of function pointers does the job.
it's not a new idea. this is basically how C programs have done polymorphism forever. but coming from javascript where everything is objects and prototypes and classes, there's something refreshing about it being this simple. a struct. some procedures. done.