Control Flow: if, when & switch
Three tools do all the work: if decides at runtime, switch decides between many possibilities at runtime, and when decides at compile time. Once you know all three, you have everything you need to express any branch in a program.
Making Decisions
A computer does exactly what it is told, in the order it is told. Everything that looks like judgement — reacting to input, handling an error, choosing a different layout — is code that chose a path. Let us look at what that choice costs and how it is written.
A Program That Chooses
The idea is simple enough to state in one sentence: evaluate a condition, and if it is true, run the block. The subtlety is when the condition can be evaluated — and that is where Odin's compile-time branch comes in.
The Anatomy of an if
An if statement has three parts: the keyword, a condition that must produce a bool, and a block of statements in braces.
temperature := 31.5
// The condition sits between `if` and the opening brace.
// The block runs only when the condition is true.
if temperature > 30.0 {
fmt.println("it is hot today")
}
The braces are not optional, even for a single statement. That is a deliberate choice: in languages where they can be omitted, a stray edit has changed the meaning of code more times than anyone can count.
The if Statement
Most decisions in a program are yes-or-no questions asked while it runs. Odin's if is deliberately plain, with one small feature that keeps related code together.
else and else if
Add else to run a different block when the condition is false, and else if to test another condition instead of nesting deeper and deeper. The chain is read from the top, and the first match wins:
score := 72
if score >= 90 {
fmt.println("excellent")
} else if score >= 70 {
fmt.println("good") // this one runs
} else if score >= 50 {
fmt.println("a pass")
} else {
fmt.println("try again")
}
Because the first match wins, the order of the tests is part of the logic. Put >= 50 first in that chain and every score above fifty would print "a pass", including a ninety. Order branches from the most specific to the most general and the problem disappears.
An Initialiser Inside the Condition
Sometimes the value you want to test has to be computed first. Odin lets you put that statement inside the if, separated from the condition by a semicolon, and the names you declare live only inside the if/else structure:
stock: map[string]int
// A map lookup produces two values: the value, and whether the key was found.
// The lookup runs first; the condition then tests the second value.
if count, ok := stock["apples"]; ok {
fmt.println("we have", count)
} else {
fmt.println("no apples in stock")
}
// `count` and `ok` do not exist out here — they are scoped to the if.
This pattern is worth adopting on purpose, because it removes a whole category of leak: a temporary value that was only meaningful for one decision cannot be used by mistake three lines later.
Conditions Must Be Booleans
Odin has no truthy values, so a condition must be an actual bool. The comparison is mandatory, and it documents the intent:
count := 0
name := ""
// if count { ... } // ERROR: an int is not a bool
if count != 0 {
fmt.println("not empty")
}
// if name { ... } // ERROR: nor is an empty string
if len(name) == 0 {
fmt.println("no name yet")
}
Coming from a language where 0, "", and null are all quietly false, the first week feels fussy. The payoff is that you never again have to remember which falsy values this codebase treats as false — because the author had to write it down.
if and switch choose a branch while the program runs; when chooses while the compiler runs, so only one branch ever reaches the binary.Compile-Time Decisions: when
Here is the idea that makes Odin feel different from most languages you may know. A when block looks like an if, but it is decided while the program is being compiled. The branch that does not apply is not skipped — it is never compiled at all.
What when Does
Odin defines several constants about the build itself, such as which operating system you are targeting. Plain if cannot use them meaningfully, because code for another platform might not even be legal to compile. when solves exactly that problem:
// Only ONE of these blocks is compiled into the program.
when ODIN_OS == .Windows {
fmt.println("this build targets Windows")
} else when ODIN_OS == .Linux {
fmt.println("this build targets Linux")
} else {
fmt.println("some other platform")
}
Think about what that means for the final binary: the version you built on Windows contains no Linux code at all. No dead branch, no runtime test, no wasted bytes. It is the same mechanism Odin uses to keep a single codebase working across systems without littering it with platform puzzles.
when, else when, else
The structure mirrors if/else if/else, and for good reason — you already know how to read it. The difference is only when the tests happen:
when— tested by the compiler, this time.else when— tested only if the previous condition was false.else— the fallback, and it must be reachable from a compile-time decision you can actually evaluate.
Everything inside a when condition must be known at compile time. You cannot test user input with it, and the compiler will tell you so — which is a kindness, because it means a mistyped condition can never silently become a runtime branch.
when as an Expression
when also works in the middle of an expression, in the form value when condition else value. Read it as a sentence: "this value when the condition holds, otherwise that one."
// The shape used by the standard library itself: pick a constant that
// depends on how wide the machine's int is — decided when you compile.
INT_MAX :: 9223372036854775807 when size_of(int) == 8 else 2147483647
fmt.println(INT_MAX) // 9223372036854775807 on a 64-bit machine
This is the compile-time cousin of the ternary operator you may know from other languages, and it has the same pleasant property: the value is fixed before your program starts, so reading it costs nothing.
The switch Statement
An if/else if chain compares one value against several possibilities. Odin, like most modern languages, gives that job its own statement — and then makes it safer than the C version you might be picturing.
A Cleaner Ladder
switch takes a value and a list of case labels. The matching case runs, and case: with nothing after it is the catch-all:
day := 6
switch day {
case 1:
fmt.println("Monday")
case 2:
fmt.println("Tuesday")
case:
fmt.println("some other day") // 6 lands here
}
Compared with the equivalent chain of else if lines, the switch version has less punctuation, no repeated subject, and the fallback is impossible to miss. When you find yourself comparing the same value three times or more, reach for switch.
Several Values, One Case
A case can list as many values as you like, which collapses parallel branches into one readable line. And a case can be a range, which is exactly what you want for bands of values:
grade := 'B'
switch grade {
case 'A', 'B':
fmt.println("well done") // 'B' lands here
case 'C':
fmt.println("solid")
case 'D', 'F':
fmt.println("needs work")
case:
fmt.println("unknown grade")
}
score := 87
// Ranges read beautifully for thresholds — no arithmetic in the condition.
switch score {
case 90..=100:
fmt.println("excellent")
case 70..<90:
fmt.println("good") // 87 lands here
case:
fmt.println("keep going")
}
Notice how the range version removes the arithmetic from the conditions. With an else if ladder you would be writing score >= 70 && score < 90 and silently hoping you got the boundaries right. The range states the band directly, so the boundary is visible, and off-by-one mistakes stop being possible.
No Fallthrough by Default
If you have written C, you are bracing for the missing break. There isn't one: in Odin, control never continues into the next case unless you ask it to, in writing.
day := 1
switch day {
case 1:
fmt.println("Monday")
fallthrough // deliberately continue into the next case
case 2:
fmt.println("Tuesday")
case:
fmt.println("some other day")
}
Running that prints Monday and then Tuesday, because fallthrough is an explicit instruction. This is the kind of design decision Odin makes over and over: the behaviour people usually want is the default, and the unusual behaviour has to be spelled out where a reviewer will see it.
Exhaustive by Default: #partial
When you switch on an enum, the compiler insists that every variant is handled. That sounds strict until you consider what it buys you:
Weather :: enum { Sunny, Rainy, Snowy }
today: Weather = .Sunny
// Every variant is handled. Now add a fourth variant to the enum, and
// this switch stops compiling until you have thought about it.
switch today {
case .Sunny:
fmt.println("bring a hat")
case .Rainy:
fmt.println("bring an umbrella")
case .Snowy:
fmt.println("bring boots")
}
That is a checklist that maintains itself. Add Foggy to the enum in a year's time and the compiler walks you to every place in the codebase where a decision must now be made. In most languages that work is left to your memory and your tests.
Sometimes, though, ignoring most variants is genuinely correct. Then you say so out loud with #partial:
// #partial means: "I intend to handle only some of these cases."
// Any variant not listed here quietly does nothing.
#partial switch today {
case .Sunny:
fmt.println("bring a hat")
}
Adding a catch-all case: also satisfies the compiler, which is why the two features can look redundant at first glance. The difference is intent: a catch-all says "everything else is handled the same way", while #partial says "everything else is none of my business". Choosing between them is a small design decision each time — and the compiler makes you make it.
Writing Readable Conditions
Choosing a branch is easy; choosing readably is the part that separates code people enjoy from code people avoid. Two habits do most of the work.
Guard Clauses and Early Returns
Nesting is the enemy of clarity. Deeply indented code forces the reader to hold several conditions in their head at once. The fix is to deal with the refusals first and return, so the main path stays flat:
// Harder to read: the real decision is buried in two levels of nesting.
check_access :: proc(age: int, tickets: int) -> bool {
if age >= 18 {
if tickets > 0 {
return true
}
}
return false
}
// Easier to read: each refusal is named, and the happy path is last.
check_access :: proc(age: int, tickets: int) -> bool {
if age < 18 {
return false // too young — nothing else matters
}
if tickets <= 0 {
return false // no ticket — nothing else matters
}
return true // by elimination, entry is allowed
}
Both functions behave identically. The second one, however, can be read top to bottom without holding anything in your head: by the time you reach the final line, every reason to refuse has already been ruled out. This pattern is called a guard clause, and you will see it constantly in Odin code — including in the standard library.
Choosing the Right Tool
Three tools, three jobs. When you are unsure which to reach for, ask what the question is and when its answer exists:
| Your situation | Reach for | Why |
|---|---|---|
| One yes/no test, decided while running | if | Simplest thing that expresses the choice |
| Several possibilities from the same value | switch | States the subject once; exhaustiveness is checked |
| Bands of values or a group of matching labels | switch with ranges | Boundaries are visible instead of hidden in arithmetic |
| The answer is fixed before the program runs | when | The unused branch is never compiled |
| The answer decides which code even exists on this system | when | Platform-specific code cannot be an if |
| A short value chosen between two options | value when cond else value | Reads as a sentence, costs nothing at runtime |
Where This Goes Next
You can now choose between possibilities. The last piece of control flow is repeating something deliberately — which is what loops are for, and Odin's single loop keyword has a few pleasant surprises in it.
The Page in One Breath
iftakes a condition that must be abool— Odin has no truthy values.- An initialiser before the semicolon keeps a temporary value scoped to the
ifthat needed it. - Order an
else ifchain from the most specific test to the most general, because the first match wins. whenis decided while compiling: the losing branch never reaches the binary. It also works mid-expression as value when condition else value.switchcompares one value against many: several labels per case, ranges, an optional catch-allcase:, and no implicit fallthrough.- Switching on an enum is exhaustive by default — a self-maintaining checklist — unless you say
#partial. - Guard clauses keep the happy path flat and the reasons for refusal explicit.
switch over ranges, then add a when expression that picks a label depending on whether this is a debug build, and print both. The whole page fits in ten lines of your own code.
Continue with Loops & Iteration →