The staircase problem
Picture a function that validates a user input string. The first check is for an empty string. The second checks for a specific prefix. The third validates the length. The fourth checks for forbidden characters. You start with if. You add else if. You add another. The indentation drifts right. The code becomes a staircase. You want to flatten it. You want a structure that groups these conditions visually without nesting them. Go provides a tagless switch for exactly this situation.
A tagless switch omits the expression after the switch keyword. It behaves as if the tag is the boolean constant true. Each case contains a boolean expression. The runtime evaluates the cases sequentially. The first case that evaluates to true executes. If no case matches, the default block runs. This pattern replaces a chain of if and else if statements with a flat, aligned block. It makes the control flow easier to scan because every condition starts at the same indentation level.
The visual alignment is the primary benefit. In a long if/else chain, the conditions are buried inside the nesting. In a tagless switch, the conditions are listed vertically. You can scan the cases quickly to understand the logic. The structure signals that these conditions are related and mutually exclusive in intent, even if they are not mutually exclusive in value.
How tagless switch works
A tagless switch is syntactic sugar for switch true. The compiler treats the tag as a constant boolean value. It does not evaluate the tag at runtime. It evaluates the cases. Each case is a boolean expression. The runtime checks the first case. If it is true, it executes the block and exits the switch. If it is false, it moves to the next case. This continues until a case matches or the list is exhausted.
The compiler does not generate a jump table for a tagless switch. A jump table works only when the switch tag is an integer or string constant, allowing the compiler to map values to memory addresses directly. A tagless switch involves arbitrary boolean expressions. The compiler translates it into a sequence of conditional branches. It checks the first case. If false, it jumps to the second. If true, it executes the block and jumps to the end of the switch. This means the order of cases is part of the logic. Swapping two cases changes the behavior if the conditions overlap.
The default block is optional. If you omit it and no case matches, the program continues after the switch statement. This is different from a tagged switch where the tag might not match any case. In a tagless switch, the tag is always true. The cases determine the flow. If no case is true, the switch does nothing. This can be a source of bugs if you assume a case will always match. Always include a default block to handle unexpected input or to make the fallback behavior explicit.
Minimal example
Here's the simplest tagless switch: check ranges, print a category, stop at the first match.
package main
import "fmt"
func main() {
score := 85
// Tagless switch evaluates conditions in order.
// The first true case wins.
switch {
case score >= 90:
fmt.Println("A")
case score >= 80:
fmt.Println("B")
case score >= 70:
fmt.Println("C")
default:
fmt.Println("F")
}
}
The order of cases is critical. The case case score >= 70 must come after case score >= 90. If you reverse them, a score of 95 matches case score >= 70 first. The output becomes C instead of A. The runtime does not check all cases. It stops at the first match. This is efficient but requires careful ordering. Place the most specific conditions first. Place the broadest conditions last. The default block catches everything else.
Realistic validation
Here's a realistic validation function: normalize input, check constraints, return errors.
package main
import (
"fmt"
"strings"
)
// ValidateEmail checks basic email format rules.
// It returns an error string or an empty string if valid.
func ValidateEmail(email string) string {
// Tagless switch keeps validation rules flat.
// Each case checks a specific constraint.
switch {
case len(email) == 0:
return "email cannot be empty"
case !strings.Contains(email, "@"):
return "email must contain @"
case strings.HasPrefix(email, "@"):
return "email cannot start with @"
case strings.HasSuffix(email, "@"):
return "email cannot end with @"
}
// No error found.
return ""
}
func main() {
fmt.Println(ValidateEmail("user@domain.com"))
fmt.Println(ValidateEmail("@domain.com"))
}
This function replaces a chain of if statements. The conditions are aligned. The return values are aligned. The logic is easy to scan. The default block is omitted because the function returns an empty string if no error is found. This is a common pattern for validation functions. The switch handles the error cases. The code after the switch handles the success case.
Variable declaration in cases
You can declare variables inside a case clause. The variable is scoped to that case block. This keeps the main scope clean and avoids polluting the function with temporary variables. The declaration happens before the condition check. If the condition is true, the variable is available in the case body.
Here's how to use variable declaration: compute a value, check it, use it in the block.
package main
import "fmt"
func main() {
data := "short"
// Declare a variable in the case clause.
// The variable is scoped to the case block.
switch {
case n := len(data); n > 10:
fmt.Printf("long: %d\n", n)
case n := len(data); n > 5:
fmt.Printf("medium: %d\n", n)
default:
fmt.Println("short")
}
}
The variable n is declared in each case. It is not shared between cases. Each case has its own scope. This is useful when you need to compute a value for the condition and use it in the block. It avoids repeating the computation. It keeps the variable local. The compiler ensures the variable is initialized before use. If the condition is false, the variable is not created. This is efficient and safe.
Pitfalls and compiler errors
Order matters. If you check a broad condition before a narrow one, the narrow case never runs. This is the most common bug in tagless switches. Always order cases from most specific to most general. Test the order with edge cases. Verify that the logic behaves as expected.
Complex expressions hurt readability. If a case contains a long function call or a multi-line expression, extract it into a helper function or a local variable. The case should be a simple boolean check. Complex logic belongs in functions. The switch should coordinate the flow, not perform the computation.
The compiler rejects non-boolean cases. If you forget to write a boolean expression, you get invalid case ... in switch (mismatched types ... and bool). This happens when you accidentally put a non-boolean value in a case, like case x: instead of case x != 0:. The compiler is strict about types. It ensures the logic is correct. Fix the error by adding a comparison operator.
Fallthrough is rare and usually a code smell. You can use fallthrough to execute the next case. This is rarely useful in a tagless switch and often indicates a logic error. Use it only when you intentionally want to chain behaviors. Prefer explicit logic over fallthrough. Fallthrough obscures the flow. It makes the code harder to read.
The missing default trap is silent. If you assume a case will always match but it does not, the program continues without warning. This can lead to unexpected behavior. Always include a default block to handle unexpected input or to make the fallback behavior explicit. The default block acts as a safety net. It catches bugs early.
Convention asides
Go has strong conventions for code structure. Follow them to keep your code readable and idiomatic.
gofmt is the standard. Don't argue about indentation; let the tool decide. Most editors run it on save. It aligns the case keywords in a tagless switch. It ensures consistent formatting across the codebase. Trust the tool. Focus on logic, not formatting.
Error handling is verbose by design. The standard pattern is if err != nil { return err }. The community accepts the boilerplate because it makes the unhappy path visible. Do not use a tagless switch to check for errors unless you are categorizing them. Type switches are better for inspecting error types. Keep error handling simple and explicit.
Receiver naming matters. If this logic lives in a method, name the receiver with a short variable matching the type. (p *Parser) Validate() is correct. (this *Parser) is not. Go does not use this or self. Use a one or two letter name. It keeps the code concise and idiomatic.
Public names start with a capital letter. Private names start with a lowercase letter. There are no keywords like public or private. The case determines visibility. Exported functions and types start with a capital. Internal helpers start with a lowercase. This convention is universal in Go. Follow it strictly.
Decision matrix
Use a tagless switch when you have multiple independent boolean conditions that determine a single outcome. Use a tagged switch when you are matching a single variable against a set of constant values. Use an if statement when you have only one or two conditions, or when the logic branches into completely different code paths rather than a single result. Use a type switch when you need to inspect the dynamic type of an interface value.
The choice depends on the structure of your logic. Tagless switch is for condition chains. Tagged switch is for value matching. if is for simple branching. Type switch is for interface inspection. Pick the tool that matches the pattern. Don't force a switch where an if is clearer. Don't force an if where a switch is flatter. Match the structure to the logic.
Where to go next
Order is logic. Change the order, change the behavior. Keep cases simple. Complex logic belongs in functions. Tagless switch is structure, not magic.