Control Flow: if, when & switch

Until now our programs have run straight down the page. This lesson is about choosing: running one block instead of another, and — Odin's own twist — making some of those choices while the program is still being compiled, so they cost nothing at all at runtime.

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.

Left: a runtime decision where a condition is evaluated as the program runs and one branch executes. Right: a compile-time when decision where the false branch is erased from the program entirely.
The two kinds of decision in Odin. 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 situationReach forWhy
One yes/no test, decided while runningifSimplest thing that expresses the choice
Several possibilities from the same valueswitchStates the subject once; exhaustiveness is checked
Bands of values or a group of matching labelsswitch with rangesBoundaries are visible instead of hidden in arithmetic
The answer is fixed before the program runswhenThe unused branch is never compiled
The answer decides which code even exists on this systemwhenPlatform-specific code cannot be an if
A short value chosen between two optionsvalue when cond else valueReads as a sentence, costs nothing at runtime
Thinking in Odin: the interesting question is rarely "which syntax is shorter?". It is "where should this decision live?" Decisions that can be made by the compiler should be, because a branch that does not exist cannot be wrong, cannot be slow, and cannot be forgotten.

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

  • if takes a condition that must be a bool — Odin has no truthy values.
  • An initialiser before the semicolon keeps a temporary value scoped to the if that needed it.
  • Order an else if chain from the most specific test to the most general, because the first match wins.
  • when is decided while compiling: the losing branch never reaches the binary. It also works mid-expression as value when condition else value.
  • switch compares one value against many: several labels per case, ranges, an optional catch-all case:, 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.
Well done. A small exercise that uses everything on this page: write a procedure that grades a score with a 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 →