Control Flow in Odin
Control Flow in Odin
In the previous chapter, we looked at how Odin approaches memory management. If memory is allocated, you can see where. If it’s released, you can see that too. Control flow follows this same philosophy.
Reading control flow in many production codebases is a daunting exercise in mental reconstruction. You dig through exception handlers, nested conditionals, defensive checks, and early exits before you finally uncover the code that does the actual work. Oftentimes the error is hidden in a chain of catch-rethrow-finally blocks, such that when an error occurs, you can hardly pinpoint the root cause. This leads to a whack-a-mole style of debugging.
Control flow in Odin is readable and comprehensible. There are no hidden exceptions. No implicit fallthrough. No invisible jumps across stack frames.
Odin code is imperative, therefore, what you see is what executes.
By the end of this chapter, you’ll understand:
- range-based
switchstatements, - exhaustive enum handling,
- explicit error propagation, including the
or_returnshorthand, - and how block-scoped
deferextends naturally beyond memory management.
The Programming Model
Most programming languages treat failure as something exceptional. A function either succeeds, or it abruptly changes the flow of execution by throwing an exception and hoping that something further up the call stack knows how to recover. That approach comes with the trade-off that you can’t always tell what’s going to happen just by reading the code.
Odin makes a different trade-off by choosing to make failures and control flow explicit.
If a procedure can fail, it says so in its return type. The caller receives that value and decides what to do next. Every possible path stays visible at the call site, making the flow of execution easy to follow from top to bottom.1
A Small Example
Let’s build a tiny application to show the explicit control flow nature of Odin. Afterwards, we’ll discuss the language features that make it possible.
Create a file named control_flow.odin and paste the following code in it:
package main
import "core:fmt"
App_Error :: enum {
None,
Config_Missing,
Engine_Failed,
}
App_State :: enum {
Starting,
Running,
Stopped,
}
load_config :: proc() -> App_Error {
return .None
}
start_engine :: proc() -> (int, App_Error) {
err := load_config()
if err != .None {
return 0, err
}
engine_id := 1337
return engine_id, .None
}
print_health_status :: proc(score: int) {
switch score {
case 0..=49:
fmt.println("System status: Critical")
case 50..=79:
fmt.println("System status: Stable")
case 80..=100:
fmt.println("System status: Optimal")
case:
fmt.println("System status: Unknown")
}
}
print_state :: proc(state: App_State) {
switch state {
case .Starting:
fmt.println("Application is starting")
case .Running:
fmt.println("Application is running")
case .Stopped:
fmt.println("Application has stopped")
}
}
main :: proc() {
fmt.println("Initialising application...")
defer fmt.println("Cleanup complete")
engine_id, err := start_engine()
if err != .None {
fmt.printfln("Fatal error: %v", err)
return
}
fmt.printfln("Engine started with ID: %v", engine_id)
print_health_status(82)
print_state(.Running)
}
Run it with:
odin run control_flow.odin -file
and you should see the following output:
Initialising application...
Engine started with ID: 1337
System status: Optimal
Application is running
Cleanup complete
Change the return value of load_config and you’d get a different result.
The code demonstrates the explicit control flow, error handling, and bounds checking in Odin. As we work through the rest of the chapter, we will revisit this example to see how each feature contributes to making the control flow obvious rather than implicit.
Before we continue, it’s worth noting that this chapter isn’t a complete tour of Odin’s control flow statements. Familiar constructs like if/else, for loops, and labelled statements work similarly to other languages. The Control Flow section of the official Odin documentation is an excellent companion to this chapter.
Switch Statements
Let’s start with the health check.
print_health_status :: proc(score: int) {
switch score {
case 0..=49:
fmt.println("System status: Critical")
case 50..=79:
fmt.println("System status: Stable")
case 80..=100:
fmt.println("System status: Optimal")
case:
fmt.println("System status: Unknown")
}
}
If you’ve written C#, Go, or Java, this probably looks familiar at first glance. One difference is that ranges are part of the switch syntax itself.
Instead of spelling out comparisons like this:
if score >= 50 && score <= 79 {
// ...
}
you express the intent directly:
case 50..=79:
The code reads more like the rule you’re trying to describe than the mechanics needed to evaluate it.
The final case: acts as the default branch. If none of the earlier cases match, execution ends up there instead. Odin’s switch never falls through by accident. If you want a fallthrough statement, you have to write it explicitly using the fallthrough keyword like this:
switch score {
case 0..=49:
fmt.println("System status: Critical")
fallthrough // <-- explicit fallthrough statement.
case 50..=79:
fmt.println("System status: Stable")
case 80..=100:
fmt.println("System status: Optimal")
case:
fmt.println("System status: Unknown")
}
The cases are evaluated in order, and the first matching case is executed. There is no need for break, and there is no bug caused by forgetting to write one. This syntax is designed around what programmers almost always mean. I don’t remember when I last wanted to fall through to the next case, but I do remember when I forgot to write a break and spent hours debugging the resulting bug.
Exhaustive Switch statements
Now look at the other switch in the example.
App_State :: enum {
Starting,
Running,
Stopped,
}
print_state :: proc(state: App_State) {
switch state {
case .Starting:
fmt.println("Application is starting")
case .Running:
fmt.println("Application is running")
case .Stopped:
fmt.println("Application has stopped")
}
}
The first thing worth noticing isn’t exhaustive checking.
It’s the syntax.
Inside the switch, we write .Starting instead of App_State.Starting. Because the compiler already knows that state is an App_State, repeating the type adds no information. Odin lets you omit it, keeping enum-heavy code concise without sacrificing readability.
The more interesting feature appears later when your code changes.
Imagine someone adds another application state a few months from now.
App_State :: enum {
Starting,
Running,
Paused,
Stopped,
}
Every switch over App_State that doesn’t handle .Paused immediately becomes a compile-time error. The compiler assumes that if you’ve switched over an enum, you’ve considered every possible value. If that assumption becomes false, it tells you exactly where to update your code.
That might seem like a small convenience, but it is very useful in maintaining correct code. As projects grow, missing a newly added enum value is one of those bugs that’s trivial to introduce and difficult to spot in code review. Letting the compiler find them is both faster and more reliable.
Sometimes, though, you genuinely only care about a subset of values.
In those cases, make that intention explicit by using a partial switch:
#partial switch state {
case .Running:
fmt.println("Application is running")
}
The #partial directive tells both the compiler and the next person reading the code that the missing cases are intentional rather than accidental.
The pattern should be familiar by now — Odin gives you choices and asks you to make them visible.
Explicit Errors Without Repetitive Code
Everything we’ve looked at so far makes the flow of execution easier to follow.
Error handling follows the same principle.
In many languages, a procedure can suddenly stop what it’s doing and transfer control somewhere else by throwing an exception. Whether that’s the right trade-off depends on the problem you’re solving, but it does mean that some execution paths aren’t visible where the procedure is called.
Odin doesn’t have the concept of exceptions. Instead, you encode error as values in the return type. The caller receives the value and decides what to do next. For example, consider the start_engine procedure:
start_engine :: proc() -> (int, App_Error)
Calling it looks like this:
engine_id, err := start_engine()
if err != .None {
fmt.printf("Fatal error: %v\n", err)
return
}
Nothing magical happens here. The success and failure path are visible in one place:
- The procedure returns.
- You inspect the result.
- You decide what happens next.
Once you’ve written a little Odin, though, you’ll notice a familiar pattern emerging.
Every fallible call follows the same shape:
err := function_that_can_fail()
if err != .None {
return err
}
The problem with explicit or fine-grained control is that it can become grudgingly repetitive, perhaps making it harder to read the code that matters. Odin’s answer in the case or error handling isn’t to hide the error handling. It’s to compress the repetitive part while leaving the control flow explicit.
That’s exactly what or_return does.
start_engine :: proc() -> (value: int, error: App_Error) {
load_config() or_return
engine_id := 1337
return engine_id, .None
}
This reads naturally as:
Attempt to load the configuration, or return.
If the call succeeds, execution continues with the next statement.
If it returns an error, that value is immediately returned from the current procedure.
No exception is thrown. No hidden stack unwinding occurs. No invisible control flow to chase.
The concept of or_return works by popping off the end value in a multiple valued expression and checking whether it was not nil or was false. If the procedure only has one return value, it will do a simple return. If the procedure had multiple return values, it will require that all parameters be named so that the end value could be assigned to by name and then an empty return could be called.2
defer: Beyond Memory Management
In the previous chapter, we used defer to release memory although defer isn’t limited to memory management. A defer statement defers the execution of a statement until the end of the scope it is in.
Here’s the example from earlier:
defer fmt.println("Cleanup complete")
When execution leaves the block it was declared in, that statement runs automatically.
Unlike languages where deferred work is tied to an entire function or closure, Odin ties it to lexical scope.3 That means you can place cleanup exactly where a resource is acquired, even inside a conditional or a loop, and know that the cleanup will happen when that scope ends.
The result is code that’s easier to read because related operations stay together.
By now, a pattern should be emerging. Whether it’s memory management, control flow statement, error handling, or defer, Odin keeps pushing the same idea: Keep behaviour close to where it matters.
The less your reader has to remember while reading, the easier your code becomes to understand.
What We’ve Learned
At first glance, this chapter might look like it covered four unrelated language features: switch, exhaustive enum handling, or_return, and defer. They’re all expressions of the same principle of visible and comprehensible code and control flow:
- A
switchsays exactly which values it handles. The compiler tells you when you’ve forgotten a new enum case. - Procedures return failures as values instead of unexpectedly changing the flow of execution.
or_returnremoves repetitive code without hiding what happens next.deferkeeps cleanup next to acquisition, so the lifetime of a resource stays visible instead of being scattered across a procedure.
The language rarely tries to be clever on your behalf. Instead, it asks you to be explicit about your intent and then gives you concise syntax for expressing it. As a result, you spend less time wondering “How did execution get here?”, and more time thinking about the problem you’re trying to solve.
If there’s one idea I’d like you to take away from this chapter, it’s this:
Good control flow isn’t about writing fewer lines of code. It’s about making the path through your program obvious to the next person who reads it, even if that person is you six months from now.
Looking Ahead
So far we have covered two of the ideas that make Odin feel different in practice: explicit memory management and explicit control flow.
Individually, these ideas are useful. Together, they reveal something more interesting in Odin — a language that consistently optimises for code that’s easy to reason about. The next chapter builds on that idea by exploring how Odin lets you write useful APIs without reaching for inheritance, interfaces, or runtime dispatch.
Notes & References
Footnotes
-
If you’ve written Go before, this philosophy will feel familiar. Odin shares the idea that errors are explicit values, but builds additional language features, such as
or_returnand exhaustiveswitchstatements, around that same principle. ↩ -
If you’re coming from Go, you can think of
or_returnas codifying one of the language’s most common error-handling patterns. Instead of relying on convention, Odin makes the intent part of the language itself. ↩ -
The defer construct in Odin differs from Go’s defer, which is function-exit and relies on a closure stack system. ↩