A Condition Is a Sentence, Not a Value
Coming from C, Java, or Python, your fingers want to write three things Go does not accept:
if count { } // compile error — count is an int, not a bool
if (count > 0) { } // compiles, but the parentheses are noise
count = f()
if count > 0 { } // fine — but see below for what Go uses instead
The first rejection defines Go's conditional philosophy: a condition must be a boolean expression. There is no truthiness — 0 is not false, "" is not false, a nil pointer is not false. Where C would silently take the else branch for an empty string, Go refuses to compile the guess. The price is verbosity (if s != "" instead of if s); the payoff is that every branch's intent survives translation, and no reader has to remember which values their language considers falsy.
Parentheses are not part of the syntax — if, for, and switch never require them — and while if (a > b) parses, gofmt leaves your redundancy in place and every other Go file in the repo writes the bare form. Braces, conversely, are mandatory even for single statements. The reason is mechanical and worth knowing: Go inserts semicolons at newlines after closing tokens, so a brace dropped to its own line after ) or a condition turns into a syntax error. "Opening brace on the declaration line" is the compiler enforcing consistency, not a style guide.
The second rejection is subtler. In C, if (err = f()) — a single = that assigns and tests at once — is a legendary source of bugs. Go's grammar eliminates it by construction: assignments (=, :=, ++) are statements, not expressions, so they cannot appear where a condition's expression is expected. The compiler never has to warn you about it. What Go offers instead is better — an init statement that runs the call, binds the result, and tests it, all scoped to the branch — and that is the shape error handling in this language is built on.
So the vocabulary of a Go condition is exactly the operators from Operators and Expressions: comparisons, logical connectives with short-circuit guards, and nothing else. if u != nil && u.Active && len(u.ID) > 0 is the complete idiom — three facts, one line, ordered so the unsafe access only runs behind its guard. Everything beyond that belongs to the init statement.
The Init Statement: Check and Use in One Breath
The most common conditional in Go code does not test a variable that already exists — it fetches a value and tests that:
if user, ok := cache.Get(id); ok {
return user, nil
}
// user is NOT in scope here — the declaration lived inside the if
return loadFromDB(id)
Syntax: if <statement>; <condition> {. The statement before the semicolon runs first, its declarations bind, then the condition is evaluated — and the declared names are scoped to the if body and its else body, disappearing entirely after the statement. That scoping is the feature. The value you fetched under a validity check cannot be read on a path where the check failed, because the name does not exist there.
It kills two bug classes at once. The C shape — declare above, forget to check, use anyway — becomes impossible: there is no "above." And the sprawling shape — fetch, then a separate if err != nil, then use — collapses into one readable line where cause and effect cannot be separated by a hundred lines of edits. The two canonical forms:
// map lookup with the presence flag
if v, ok := m[key]; ok {
fmt.Println("found", v)
}
// error from a call, the backbone of Go error handling
if f, err := os.Open(path); err != nil {
return fmt.Errorf("open %s: %w", path, err)
} else {
defer f.Close()
}
(The else above is legal but discouraged — you will see the flattened version in a moment. The point is the shape: the call, the check, and the branch are one unit.)
The trap to internalize now is shadowing. Because each init statement declares its own names, this code compiles while logging the wrong error:
var err error
if resp, err := client.Get(url); err != nil { // NEW err, scoped to this if
log.Println(err)
return err
}
fmt.Println(err) // the OUTER err — still nil — not the one just checked
There is no bug in the if; the bug is assuming the init declaration wrote through to an outer err of the same name. := in an init position always declares inside the statement's scope (at least one new name is required, and resp is new). The defensive habits: declare close to use so only one err is visible at any point, and if you see two live errs in nested scopes, rename or flatten until there is one. This exact idiom — if err := ...; err != nil — is the contract shape in The error Interface and Error Messages, so the scoping rule is worth making automatic now.
Guard Clauses: Trading Pyramids for Early Exits
Here is a permission check written the way the shape arrives in most people's heads:
func canView(user User, doc Doc) bool {
if user.LoggedIn {
if doc.Owner == user.ID {
return true
} else {
if doc.Public {
return true
} else {
if user.IsAdmin {
return true
} else {
return false
}
}
}
}
return false
}
Every added condition costs an indentation level, and after three levels the reviewable logic — the actual rules — is buried in whitespace. The rewrite applies one principle: handle the failure cases first and return, so the success path runs down a flat page.
func canView(user User, doc Doc) bool {
if !user.LoggedIn {
return false
}
if doc.Owner == user.ID || doc.Public || user.IsAdmin {
return true
}
return false
}
Same behavior, half the lines, and each rule is scannable in isolation. Two supporting rules keep it honest. First, else after a return (or break/continue) is dead weight — the branch can never run, so the braces buy nothing:
if err != nil {
return err
}
// no 'else' needed — nothing below runs on the error path
return process(doc)
Second, the compiler agrees: unreachable code after return is precisely what go vet's analysis flags, so the redundant form does not merely read worse, it invites a diagnostic.
When is else earned? When both branches assign a single value and neither returns — a two-way choice reads as one decision rather than two guards:
var status string
if resp.StatusCode >= 400 {
status = "error"
} else {
status = "ok"
}
(Or use the operators topic's constraint — there is no ternary in Go, so this is the idiom.) And three or more branches dispatching on one value is no longer an if/else if chain at all: switch Statements exists for exactly that, with none of the nesting and none of the repeated ==.
The judgment to carry forward: an if with an else inside an if inside an else is a signal, not a structure. Flatten with guards while the function still fits on a screen; beyond four or five conditions, the answer is a switch, a table, or a function — all covered as this roadmap moves from branching to dispatch.
Quiz
1. Given `if v, ok := m[key]; ok { ... }`, where can v and ok be referenced?
2. You write `if count := len(items); count {`. What does the compiler report?
3. After `if resp, err := client.Get(url); err != nil { return err }`, a later line prints the function's `err` variable. What does it show?
4. A function nests four levels of if/else to express three allow-rules and one default deny. What is the idiomatic restructure?
Flashcards
Question
What can appear in a Go if's init position, and where do its declarations live?
Click to reveal answer
Answer
Any simple statement — a short declaration, assignment, or function call — separated from the condition by a semicolon: `if v, ok := m[k]; ok`. Its variables are scoped to the if statement and any else attached to it, and do not exist before or after it, which is how fetched-under-check values stay unusable on failed paths.
Question
Why does `if count {` fail to compile while `if (count > 0) {` succeeds?
Click to reveal answer
Answer
Conditions must be bool: Go has no truthiness, so an int count is rejected outright. The parenthesized version compiles because `count > 0` is a proper boolean expression — the parentheses are merely unconventional noise that gofmt leaves alone and no Go codebase writes.
Question
Why is assignment-in-condition — C's classic `if (err = f())` bug — impossible in Go?
Click to reveal answer
Answer
Assignments (=, :=, ++, --) are statements, not expressions, so they cannot occupy the condition position at all. The grammar has no path from an assignment to a boolean context; Go replaces the idiom with the init statement `if err := f(); err != nil`, which is unambiguous and scoped.
Question
What is the guard-clause pattern, and when is an else redundant?
Click to reveal answer
Answer
Invert each failure condition into an early return at the top (`if !ok { return ... }`), so the remaining success logic runs flat instead of nested. An else after a return/break/continue is unreachable on that path and can be deleted — go vet's unreachable analysis flags the same structure.
Question
How do you look up a map key in a conditional without confusing absence with a zero value?
Click to reveal answer
Answer
Use the comma-ok form in an init statement: `if v, ok := m[key]; ok { use(v) }`. The two-result form distinguishes 'present with value 0' from 'absent' — a bare `v := m[key]` returns the zero value for both, so a stored 0 and a missing key are indistinguishable.
Revision Notes
Key Takeaways
- 1. Conditions are boolean expressions only — explicit comparisons replace truthiness, and the grammar makes assignment-in-condition unparseable
- 2. The init statement binds the fetched value and its check into one scoped unit, so unchecked values cannot be read afterward
- 3. Guard clauses beat nesting: return early on failures and keep the success path at one indentation level
- 4. Every `:=` in a nested scope declares a new variable — the shadowed-err confusion is a scoping fact, not a compiler quirk
- 5. else after return is unreachable weight; vet will flag it, and for 4+ branches on one value the answer is switch
Interview Tips
- • "How does Go prevent the classic assignment-in-condition bug?" — assignments are statements, not expressions, so they cannot appear in a condition; the replacement is the init statement, which you can write out: `if err := f(); err != nil`
- • "What does the init statement's scope cover?" — the if body and its else body, nothing else; use the map comma-ok example and note that's why a fetched value can't be used after a failed check
- • "Walk me through reducing deeply nested code" — guard clauses: invert conditions to early returns, delete else-after-return, then say when you'd switch to a switch (four or more branches on one value)
- • "What's the shadowing trap in conditionals?" — inner `:=` declares fresh variables with the same names; show the client.Get example where a later `err` refers to the untouched outer one
Cheat Sheet
Conditionals with if and else
Syntax shapes:
| Shape | Use |
|---|---|
if cond { } |
test something that already exists |
if stmt; cond { } |
fetch and test — declaration scoped to if/else |
if a { } else if b { } else { } |
2–3 distinct branches |
guard: if bad { return } |
flatten nesting, keep success path flat |
Rules:
- Condition must be
bool— no truthiness, no parentheses needed, braces mandatory - Assignments are statements → C's
if (err = f())bug cannot parse - Init-statement names live in the if/else statement only; inner
:=shadows outer names of the same identifier elseafterreturnis dead code (go vet flags unreachable code)
Code template:
// fetch + check, one unit
if user, ok := cache.Get(id); ok {
return user, nil
}
// error idiom (shape used across all Go code)
if f, err := os.Open(path); err != nil {
return fmt.Errorf("open %s: %w", path, err)
}
defer f.Close()
// guard-first structure
if !ok {
return ErrNotFound
}
if !canWrite(user, doc) {
return ErrForbidden
}
return save(doc)
Decision rule: 1–3 conditions → if/guards · 4+ branches on one value → switch.
Continue Learning
Before You Start
- No prerequisites listed.