Switch and pattern dispatch
Go does not have pattern matching in the sense of Rust, Haskell, or Scala — there is no destructuring, no algebraic-data-type analysis, and no exhaustive checking. The conventional dispatch construct is switch, which has two principal forms: expression switch (matching on a value) and type switch (matching on the dynamic type of an interface). Each switch arm runs to completion (no implicit fallthrough — the C-family default), the arms are checked in order, and the construct is a statement rather than an expression. The combination — switch with implicit break, the type-switch form for interface dispatch, the init clause shared with if and for — is the substance of Go’s value-driven dispatch.
Expression switch
The principal form:
switch n {
case 0:
fmt.Println("zero")
case 1:
fmt.Println("one")
case 2, 3, 4: // multiple values per case
fmt.Println("low")
case 5, 6, 7, 8, 9:
fmt.Println("high")
default:
fmt.Println("other")
}
The discriminant follows switch; each case lists one or more values; default matches anything not covered. The cases are tried in order; the first matching case’s body runs; no fallthrough — the body runs to completion and exits the switch.
No fallthrough by default
Unlike C-family switch, Go’s cases do not fall through. The body of each case runs once and the switch exits:
switch n {
case 1:
fmt.Println("one")
// implicit break — no fallthrough
case 2:
fmt.Println("two")
}
To explicitly fall through, use the fallthrough keyword:
switch n {
case 1:
fmt.Println("one")
fallthrough
case 2:
fmt.Println("two") // also runs if n == 1
}
The fallthrough is rarely used; conventional Go style avoids it. The default no-fallthrough behaviour eliminates a substantial class of bugs.
Conditionless switch
Go admits switch without a discriminant — equivalent to if/else if/else:
switch {
case n < 0:
fmt.Println("negative")
case n == 0:
fmt.Println("zero")
case n < 10:
fmt.Println("small")
default:
fmt.Println("large")
}
The form is conventionally clearer than long if/else if chains for value-driven branching.
Init clause
switch admits an initialisation clause, like if:
switch n := compute(); { // empty discriminant; no value
case n < 0:
return "negative"
case n == 0:
return "zero"
default:
return "positive"
}
switch n := compute(); n { // discriminant after init
case 0, 1:
return "low"
default:
return "other"
}
The variable n is scoped to the switch and its cases.
Type switch
A type switch dispatches on the dynamic type of an interface value:
var i interface{} = "hello"
switch v := i.(type) {
case nil:
fmt.Println("nil")
case int:
fmt.Printf("int: %d\n", v)
case string:
fmt.Printf("string: %s\n", v)
case []byte:
fmt.Printf("bytes: %x\n", v)
case error:
fmt.Printf("error: %v\n", v)
default:
fmt.Printf("unknown type %T: %v\n", v, v)
}
The form switch v := i.(type) is special syntax — (type) is recognised only in switch. The variable v is bound in each case to the matched type.
The mechanism admits substantial flexibility for handling values whose static type is interface{} or some other interface. Conventional uses include JSON deserialisation, error type discrimination, and value-driven processing of heterogeneous data.
For a single-type extraction, use a type assertion:
if s, ok := i.(string); ok {
fmt.Println(s)
}
Multiple values per case
Each case may list multiple values, separated by commas:
switch day {
case "Saturday", "Sunday":
fmt.Println("weekend")
case "Monday", "Tuesday", "Wednesday", "Thursday", "Friday":
fmt.Println("weekday")
}
The mechanism admits compact dispatch for sets of values.
Type-switch with multiple types per case
Type switch admits multiple types per case:
switch v := i.(type) {
case int, int64:
fmt.Printf("integer: %d\n", v) // v's type is interface{} (the common type)
case float32, float64:
fmt.Printf("float: %f\n", v)
case string:
fmt.Printf("string: %s\n", v)
}
A subtlety: when a case has multiple types, the variable v retains the interface type — the compiler cannot narrow to a specific one of the listed types.
Common patterns
State machine
switch state {
case StateIdle:
return startProcessing(data)
case StateRunning:
return continueProcessing(data)
case StateDone:
return finalise(data)
case StateFailed:
return fmt.Errorf("processing already failed")
}
Error type dispatch
err := operation()
switch e := err.(type) {
case nil:
return nil
case *NetworkError:
return retryAfter(e.RetryAfter)
case *ValidationError:
return fmt.Errorf("validation: %s", e.Field)
case *AuthError:
return ErrUnauthorized
default:
return fmt.Errorf("unknown: %w", err)
}
The pattern is conventional for Go’s typed errors; errors.As (Go 1.13+) admits a more flexible form for wrapped errors:
var netErr *NetworkError
if errors.As(err, &netErr) {
return retryAfter(netErr.RetryAfter)
}
JSON value dispatch
func formatValue(v interface{}) string {
switch x := v.(type) {
case nil:
return "null"
case bool:
if x { return "true" }
return "false"
case float64:
return strconv.FormatFloat(x, 'f', -1, 64)
case string:
return strconv.Quote(x)
case []interface{}:
return formatArray(x)
case map[string]interface{}:
return formatObject(x)
default:
return fmt.Sprintf("%v", x)
}
}
The pattern is conventional for processing encoding/json values.
Range-based dispatch
switch {
case n < 0:
return "negative"
case n == 0:
return "zero"
case n < 10:
return "small"
case n < 100:
return "medium"
default:
return "large"
}
The conditionless switch is conventional for range-driven dispatch.
Integer-to-string lookup
func dayName(n int) string {
switch n {
case 1: return "Monday"
case 2: return "Tuesday"
case 3: return "Wednesday"
case 4: return "Thursday"
case 5: return "Friday"
case 6: return "Saturday"
case 7: return "Sunday"
default: return "invalid"
}
}
For substantially larger lookups, a map is conventionally clearer:
var dayNames = map[int]string{
1: "Monday", 2: "Tuesday", 3: "Wednesday",
4: "Thursday", 5: "Friday", 6: "Saturday", 7: "Sunday",
}
func dayName(n int) string {
if name, ok := dayNames[n]; ok {
return name
}
return "invalid"
}
Token-based dispatch (parser)
switch tok.Kind {
case TokInt:
return parseInt(tok)
case TokString:
return parseString(tok)
case TokOpenParen:
return parseGroup()
case TokIdent:
return parseIdent(tok)
default:
return fmt.Errorf("unexpected token: %v", tok)
}
HTTP method dispatch
switch r.Method {
case http.MethodGet:
return handleGet(w, r)
case http.MethodPost:
return handlePost(w, r)
case http.MethodDelete:
return handleDelete(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return nil
}
Optional cleanup
switch err := compute(); {
case err == nil:
return result
case errors.Is(err, ErrTransient):
return retryLater()
case errors.Is(err, ErrFatal):
return abort(err)
default:
log.Printf("unexpected error: %v", err)
return err
}
Type-switch with default
switch v := value.(type) {
case int:
return fmt.Sprintf("int %d", v)
case string:
return fmt.Sprintf("string %q", v)
default:
return fmt.Sprintf("unknown type %T", v)
}
The default is conventional in type switches for handling unexpected types.
A note on the absence of pattern matching
Languages with substantial pattern matching (Rust, Haskell, Scala) admit:
- Destructuring —
case (x, y, z) =>. - Bindings within patterns —
case Some(x) if x > 0 =>. - Algebraic data type analysis — exhaustive checking on enums.
- Arbitrary nested patterns —
case (Cons(x, xs), Cons(y, ys)) =>.
Go’s switch is more limited — it dispatches on values or types but does not destructure. The conventional Go style decomposes pattern-like operations into:
- A
switchfor the principal dispatch. - A type assertion or struct field access for the “destructuring”.
// Pattern-matching style (e.g., Rust):
// match value {
// Point { x: 0, y } => println!("y-axis at {}", y),
// Point { x, y: 0 } => println!("x-axis at {}", x),
// Point { x, y } => println!("({}, {})", x, y),
// }
// Go equivalent:
switch {
case p.X == 0 && p.Y == 0:
fmt.Println("origin")
case p.X == 0:
fmt.Printf("y-axis at %d\n", p.Y)
case p.Y == 0:
fmt.Printf("x-axis at %d\n", p.X)
default:
fmt.Printf("(%d, %d)\n", p.X, p.Y)
}
The conventional Go style is more verbose but admits less syntactic surface to learn.
A note on exhaustiveness
Go’s switch does not check exhaustiveness — adding a new value to an enum-like type does not produce errors at every switch. The conventional defences:
- Always include a
defaultcase for safety. - Use a linter (
go vet,staticcheck,exhaustive) —exhaustivechecksswitchon enum types. - Use
panic("unreachable")in cases that should never occur.
const (
StatusActive Status = iota
StatusInactive
StatusBanned
)
switch s {
case StatusActive:
return "active"
case StatusInactive:
return "inactive"
case StatusBanned:
return "banned"
default:
panic(fmt.Sprintf("unknown status: %d", s))
}
A note on the conventional discipline
The contemporary Go switch advice:
- Use
switchfor value or type dispatch. - Use conditionless
switch(switch { case ... }) in place of longif/else ifchains. - Use type switches for interface-based dispatch.
- Avoid
fallthrough— the default no-fallthrough is the right behaviour. - Always include
default— defensive against new variants. - Use
errors.Isanderrors.As(Go 1.13+) for wrapped error matching. - Use a map for substantially larger value-to-result lookups.
The combination — single dispatch construct (switch), expression and type forms, no fallthrough by default, init clause, multiple values per case — is the substance of Go’s pattern-dispatch surface. The discipline trades pattern-matching expressiveness for simplicity and explicitness.