1. if / else, switch & Go's Lack of a Ternary Operator
Go's if needs no parentheses around the condition, and can include a
short initialization statement scoped to the if block itself — a
pattern you'll see constantly once error handling enters the picture next week.
age := 20
if age >= 18 {
fmt.Println("Adult")
} else if age >= 13 {
fmt.Println("Teen")
} else {
fmt.Println("Child")
}
// Initialization statement scoped ONLY to this if/else chain
if score := computeScore(); score > 90 {
fmt.Println("Excellent:", score)
} else {
fmt.Println("Needs work:", score)
}
// score is not visible here -- it only existed inside the if/else block
Go has no ?: ternary operator at all — an intentional omission. An
if/else is considered more readable, so that's the only
tool you get. switch covers the rest, and unlike C-family languages it
doesn't fall through by default:
switch day := "Tue"; day {
case "Sat", "Sun":
fmt.Println("Weekend")
case "Mon", "Tue", "Wed", "Thu", "Fri":
fmt.Println("Weekday")
default:
fmt.Println("Unknown")
}
// No "break" needed -- each case exits automatically after running
// A switch with no expression works like a cleaner if/else chain
switch {
case age < 13:
fmt.Println("Child")
case age < 18:
fmt.Println("Teen")
default:
fmt.Println("Adult")
}
2. Loops: Go Only Has for
Go has exactly one looping keyword — for — and it covers every case
other languages split across for, while and
do-while:
// Classic three-part for
for i := 0; i < 5; i++ {
fmt.Println(i)
}
// "while" loop -- just drop the init/post clauses
n := 0
for n < 3 {
fmt.Println(n)
n++
}
// Infinite loop -- exit explicitly with break
for {
if done() {
break
}
}
// range: iterate a slice, array, map or string
fruits := []string{"apple", "banana", "cherry"}
for index, value := range fruits {
fmt.Println(index, value)
}
// Ignore the index with the blank identifier when you only need the value
for _, value := range fruits {
fmt.Println(value)
}
_ shows up everywhere in Go
It's how you explicitly discard a value you're required to receive but don't need — an unused index in a range loop, or an error you're deliberately not checking (rare, and usually worth a comment explaining why).
3. Functions, Multiple Return Values & Named Returns
Go functions can return more than one value — this is how Go returns both a result and an error without exceptions, and it's the single most distinctive shape of idiomatic Go code:
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, fmt.Errorf("cannot divide %v by zero", a)
}
return a / b, nil
}
result, err := divide(10, 2)
if err != nil {
fmt.Println("Error:", err)
} else {
fmt.Println("Result:", result)
}
// Named return values -- documents intent, and "return" alone uses them
func minMax(nums []int) (min, max int) {
min, max = nums[0], nums[0]
for _, n := range nums {
if n < min {
min = n
}
if n > max {
max = n
}
}
return // returns the current values of min and max
}
That (float64, error) return shape — a value, plus an
error that's nil when nothing went wrong — is the pattern
you'll see in almost every standard-library and third-party Go function. Learn to
read it fluently now; every week after this one assumes it.
4. Idiomatic Error Handling: the error Type
Go has no exceptions for ordinary error conditions. error is just an
interface with one method, Error() string, and functions that can fail
return one as their last value. The caller checks it immediately, every time:
import (
"errors"
"fmt"
)
func parseAge(input string) (int, error) {
if input == "" {
return 0, errors.New("age cannot be empty")
}
// ... parsing logic
return 25, nil
}
age, err := parseAge("")
if err != nil {
fmt.Println("Failed to parse age:", err)
return
}
fmt.Println("Age:", age)
It looks repetitive at first, but it means every possible failure point in a call chain is visible directly in the code, right where it happens — instead of being invisible control flow that jumps somewhere else, the way a thrown exception does.
5. defer, panic & recover
defer schedules a function call to run right before the surrounding
function returns — regardless of which return statement fires. It's Go's primary
tool for guaranteed cleanup, like closing a file or releasing a lock:
func readConfig(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close() // guaranteed to run when readConfig returns, no matter how
// ... read and use f here
return nil
}
panic and recover exist for genuinely exceptional,
unrecoverable situations — not for ordinary error handling. Reach for a returned
error first; panic is the exception, not the rule:
func safeDivide(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered from panic: %v", r)
}
}()
result = a / b // panics on division by zero
return
}
Idiomatic Go reserves panic for programmer errors and truly unrecoverable states (a nil pointer you should never have dereferenced, a broken invariant). Anything a caller could reasonably expect and handle belongs in a returned error instead.
6. Hands-on Exercise
Build a small, safe calculator function
Practice control flow, multiple return values and idiomatic error handling together in one function.
Requirements:
- Write a function
calculate(a, b float64, op string) (float64, error)supporting"+","-","*"and"/", selected with aswitchonop. - Return an explicit error for division by zero, and a different explicit error for an unrecognized operator.
- In
main, loop over a slice of at least 5 test cases (including one divide-by-zero and one invalid operator) withrange, callingcalculateand printing either the result or the error. - Add a
deferat the top ofmainthat prints"done"— confirm it prints last, after every loop iteration's output. - Extra credit: wrap the division in a helper that uses
recoverto convert a runtime panic into a returned error instead, and prove it behaves identically to your explicit zero-check.
Remember that dividing two ints by zero panics at runtime, but dividing two float64s by zero returns +Inf instead — using float64 parameters means your explicit zero-check is the only thing standing between a valid result and a silently wrong one.
7. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why doesn't Go's switch statement fall through to the next case by default?
Why doesn't Go's switch statement fall through to the next case by default?
Go's designers considered C-style fallthrough a common source of accidental bugs (forgetting a break), so each case exits automatically once its block finishes. The explicit fallthrough keyword is available for the rare case you actually want the old behavior, making the intent visible in the code.
Q2
How do you write a "while" loop in Go, given there's no while keyword?
How do you write a "while" loop in Go, given there's no while keyword?
Use for with only a condition and no init/post clauses, e.g. for n < 3 { ... }. Go deliberately has one loop keyword that covers every case other languages split across for, while and do-while.
Q3
Why do most Go functions that can fail return an error as their last value instead of throwing an exception?
Why do most Go functions that can fail return an error as their last value instead of throwing an exception?
Returning an error as an ordinary value makes every possible failure point explicit and visible directly in the calling code, checked right where it happens with if err != nil. An exception's control flow, by contrast, can silently jump past several stack frames to a distant handler, which Go's designers considered harder to reason about.
Q4
When should you reach for panic/recover instead of returning an error?
When should you reach for panic/recover instead of returning an error?
Almost never for ordinary, expected failure conditions — those belong in a returned error. panic is reserved for truly unrecoverable programmer errors or broken invariants, such as a nil pointer that should never have occurred in the first place. Reaching for it to handle a normal "this input might be invalid" case is considered unidiomatic Go.