Week 2: Control Flow, Functions & Error Handling

Go keeps control flow deliberately small: one loop keyword, no ternary operator, and no exceptions. This week covers that full toolkit, how Go structures functions around multiple return values, and the idiom that replaces try/catch everywhere else — returning an error as an ordinary value and checking it explicitly.

Module 2 of 16 Week 2 of 16 ~2–3 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Write if/switch statements and every form of Go's single for loop
  • Write functions with multiple and named return values
  • Handle errors idiomatically, and explain what defer, panic and recover are each for

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.

go
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:

go
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:

go
// 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)
}
The blank identifier _ 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:

go
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:

go
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)
"if err != nil" is the most-typed line in Go for a reason

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:

go
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:

go
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
}
If you're reaching for panic/recover to handle a normal error case, stop

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

Hands-on

Build a small, safe calculator function

Practice control flow, multiple return values and idiomatic error handling together in one function.

Requirements:

  1. Write a function calculate(a, b float64, op string) (float64, error) supporting "+", "-", "*" and "/", selected with a switch on op.
  2. Return an explicit error for division by zero, and a different explicit error for an unrecognized operator.
  3. In main, loop over a slice of at least 5 test cases (including one divide-by-zero and one invalid operator) with range, calling calculate and printing either the result or the error.
  4. Add a defer at the top of main that prints "done" — confirm it prints last, after every loop iteration's output.
  5. Extra credit: wrap the division in a helper that uses recover to convert a runtime panic into a returned error instead, and prove it behaves identically to your explicit zero-check.
Hint

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?

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?

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?

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?

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.