Odin · when & build flags

One source, two builds — when, four levels deep

when is an if the compiler resolves before it generates code. The condition is a constant, so the compiler already knows the answer — it keeps the winning branch and deletes the other one entirely. The loser isn't compiled, isn't type-checked, isn't in the binary. Below is one source file; flip the build condition and watch one branch become real code while the other is struck out and gone.

Same main.odin, two builds. The struck-out lines are removed before code generation — they produce no instructions. Outputs are real program output.


          


          

The whole system

when COND { … }
compile-time branch

ODIN_OS · ODIN_ARCH
ODIN_DEBUG
built-in constants

#config(N, default)
read a build-time value

What you're seeing

Level 1 was what it is. Level 2 is the case where nothing else will do: the deleted branch's symbols don't have to exist on this target. That single property is why one source tree can compile on every platform.

Take a call that exists only on one platform — windows.GetLastError(). Write it bare, with no guard, and cross-compile to a target that doesn't have it. The build stops before it ever reaches the linker:

import "core:sys/windows"
main :: proc() {
    _ = windows.GetLastError()   // no guard, on every target
}
// $ odin build main.odin -file -target:linux_amd64
main.odin(11:6) Error: 'GetLastError' is not declared by 'windows'

Who refuses, and when: the compiler, not the linker — core:sys/windows itself gates that symbol behind when ODIN_OS == .Windows, so on a Linux target the name is never declared and the type-checker stops you up front. Now wrap the exact same call in a guard and the cross-compile succeeds:

main :: proc() {
    when ODIN_OS == .Windows {
        _ = windows.GetLastError()   // only compiled ON Windows
    }
}
// $ odin build main.odin -file -target:linux_amd64
builds clean — the Linux target never compiles the windows branch

The counterfactual: the runtime if cannot stand in here. Both arms of an if are compiled, type-checked, and linked — so both arms must reference symbols that exist. An if ODIN_OS == .Windows would still demand that windows.GetLastError be declared on Linux, and the Linux build would fail exactly as the bare call did. if is the right shape for a decision made at runtime; it is the wrong shape for "this platform doesn't have that function."

Why the alternatives are worse

trying to ship per-platform code without a compile-time branch
one file per OS,
built separately
runtime if,
both arms linked
stub every
foreign symbol
all three pay for the symbol to exist on a target that doesn't have it.

A separate source file per platform selected by the build system: now the shared logic is duplicated across files and drifts apart — a fix in one is a bug waiting in the other. More code, same idea, more bugs.

A runtime if ODIN_OS == .Windows: both arms compile, so the Linux build still needs the windows symbol declared — it simply won't build. Broken for exactly the platform-fork job.

Hand-declaring a do-nothing stub of every foreign symbol on the platforms that lack it: real declarations the compiler must accept and the linker must resolve, carried in the binary for nothing. Slower to maintain, dead weight shipped. The compile-time branch removes the loser outright, so there is nothing to stub.

Level 2 was platform forks. Level 3 is the dials you turn from the build command: NAME :: #config(NAME, default) reads a build-time constant, and -define:NAME=value sets it. One source, recompiled, diverges. The default's type fixes the constant's type — until an override supplies its own.

The same main.odin below has a bool flag MY_FEATURE and a string flag GREETING, both read with #config. Pick a build command and watch the compiled output change — the lines that aren't selected simply aren't in this build:


      

The two gotchas the dials hand you — both real compiler behavior, neither obvious:

# Typo the key — the value falls through to the default, output unchanged,
# but the compiler warns you the define went nowhere:
$ odin run main.odin -file -define:MY_FEATUR=true
Warning: given -define:MY_FEATUR is unused in the project

# Override a bool flag with a string — the -define is NOT rejected at the
# #config line. It replaces MY_FEATURE with "hello"; the error surfaces
# later, at the `when MY_FEATURE` that now has a non-bool condition:
$ odin run main.odin -file -define:MY_FEATURE=hello
main.odin(32:7) Error: Non-boolean condition in 'when' statement
    when MY_FEATURE {

The fix to internalize: the default's type holds only until an override is supplied; the override then decides the type, and a wrong one breaks at the use site, not the declaration. The lesson is that #config doesn't validate the override against the default's type — your when does, wherever you read it.

a when condition is a constant, not a variable The thing inside when must be known at compile time. Reach for a runtime value and the build refuses: when some_local_bool { … } gives Error: Undeclared name: some_local_bool — the compile-time branch has no access to runtime storage. If the decision genuinely depends on something only known while the program runs, that's an if, not a when. The split is the point: when decides on constants before codegen; if decides on values during execution.

The deleted branch isn't skipped — it's absent. No branch instruction, no jump, no flag check, no extra bytes in the binary. That absence is the feature, and it comes with one sharp edge worth naming.

Zero runtime cost. A when ODIN_DEBUG { … } block in a release build is byte-for-byte as if you'd never written it. This is what makes when the right home for asserts, validators, and heavy logging: you don't apologize for them in release, because they aren't there. And the flag is real — flip it and the same program reports a different build:

# default build: ODIN_DEBUG is false unless you pass -debug
$ odin run main.odin -file
Build: RELEASE

# add -debug and the same source reports the other label
$ odin run main.odin -file -debug
Build: DEBUG

The double edge: because the losing branch is removed before type-checking, the compiler never looks inside it. A name that was never declared anywhere in the program sits happily in a branch that isn't taken — and the build still succeeds:

main :: proc() {
    when ODIN_OS == .Windows {
        _ = 1
    } else when ODIN_OS == .Linux {
        this_name_was_never_declared_anywhere()  // never checked on Windows
    }
}
// on a Windows host: builds clean. the Linux arm is gone before type-checking.

The emergent payoff — and its bill. The same property that lets one source tree compile on every target (Level 2) means a typo in a dead arm hides until somebody builds that target. The compile-time wall that protects you also blinds the compiler to the branch behind it. The remedy isn't a language feature — it's building on every platform you ship to, so every branch becomes a taken branch somewhere and gets its turn through the type-checker.

That's the arc: L1 when resolves an if before codegen and deletes the loser → L2 so the deleted arm's symbols needn't exist — one source compiles on every target → L3 #config + -define turn that into build-command dials, with type checked at the use site → L4 the loser leaves zero instructions, but is also never type-checked, so build everywhere.

probes reproduce with odin build / odin run · every output, warning & error on this page is real compiler output (claims/lessons/16-when-and-build-flags)