1. Introduction
When a program is small, it feels normal to write if status == 2. But a week goes by (or one call with your team lead), and you no longer remember: “Is 2 done or in progress?”. And if you have twenty such “2” values across the project, you end up playing “guess the meaning by the code author’s face”. Enums and flags are a simple technique that turns numbers into clear names and sharply reduces the chance of mistakes.
Let’s imagine we’re building a mini console program TaskInspector. For now it can do very little: we enter a task status and some “flags” (for example, an important task or one with a deadline), and the program prints a clear description. Today we’re not doing architecture, not doing files, not doing collections—we’re simply learning to write and read code where numbers don’t look like a cipher.
Enum vs flags: these are different models
Enums and flags are similar in that both use constants, and often even iota. But their meaning is fundamentally different:
- Enum — “choose exactly one option from a set”.
- Flags — “choose any set of attributes at the same time”.
If you mix these models up, the code may compile and even “sometimes work”, but it will be painful for everyone to read—including you a month later.
A short table to lock in the difference:
| Model | The question it answers | What the check looks like | Example |
|---|---|---|---|
| Enum | “Which single state is it in right now?” | == (equality to a single value) | task status: new / in_progress / done |
| Flags | “Which attributes are enabled?” | & with a mask and comparison to 0 | is the task urgent? has a deadline? blocked? |
2. The enum pattern in Go
Your own type + constants + readable checks
When you write type Status int, you create a new type, not a “renamed int”. This is convenient: the compiler won’t let you accidentally compare a task status with something unrelated (for example, number of attempts or the price of a shawarma).
Minimal skeleton of an enum-like status type:
package main
import "fmt"
func main() {
type Status int
const (
StatusNew Status = iota
StatusInProgress
StatusDone
)
var s Status = StatusDone
fmt.Println(s == StatusDone) // true
}
Notice: we compare s == StatusDone, not s == 2. The condition reads as “status equals Done”, not “status equals 2 (why 2?)”.
Human-readable output: “number inside, word outside”
An enum almost always stores a number—that’s normal and even useful (compact). But a human needs text. That’s why next to an enum you typically have a small “translator”: logic that converts an enum value to a string.
If you print the status like this: fmt.Println(s), you’ll get 2. Formally correct, but “human-useless”. In learning examples (and in many real ones) the simplest approach is a mapping via switch:
package main
import "fmt"
func main() {
type Status int
const (
StatusNew Status = iota
StatusInProgress
StatusDone
)
s := StatusInProgress
text := "unknown"
switch s {
case StatusNew:
text = "new"
case StatusInProgress:
text = "in_progress"
case StatusDone:
text = "done"
}
fmt.Println(text) // in_progress
}
What matters here is that we have a “Plan B”—"unknown". In real programs, data can be messy (especially if it comes from outside), and “unknown” isn’t a sign of weakness; it’s a sign that you don’t take the world at face value (and you’re right not to).
3. Getting to know functions
You’ve already seen that it’s easy to accumulate a lot of logic in main: switch, a bunch of if, building strings… And very quickly main turns into a “bedsheet” that’s hard to make sense of. Functions are a way to move a piece of logic into a separate block with a name.
What a function is in simple words
A function is a “mini-program inside a program”:
- it has an input (parameters),
- an output (what it returns),
- and a body (the code that runs).
For example, we want to turn a status into text. You can write that directly in main, but it’s better to name the rule separately: statusText.
How to read a function declaration
Look at this line:
func statusText(s Status) string
It is read like this:
- func — declare a function;
- statusText — the function name;
- (s Status) — input: one parameter s of type Status;
- string after the parentheses — output: the function returns a string.
That is: “The function statusText takes a Status and returns a string”.
Return: “return a result and end the function”
The return statement does two things at once:
- returns a value to the calling code;
- ends function execution (no further code in that function runs).
You’ve already seen return as “exit main early”. In functions the meaning is the same, except now we usually return a result.
For example, in a switch it’s convenient to return a string directly from the needed branch:
func statusText(s Status) string {
switch s {
case StatusNew:
return "new"
case StatusInProgress:
return "in_progress"
case StatusDone:
return "done"
default:
return "unknown"
}
}
Notice: there is no “shared variable text” that we mutate. Instead, each branch returns the answer immediately. This is one of the most readable styles in Go: less state, fewer chances to mess up.
How a function is called (and where the result goes)
A call looks like “name + parentheses”:
t := statusText(StatusDone)
fmt.Println(t)
We call statusText(...), get a string, and then pass it to fmt.Println. You can also write it more compactly:
fmt.Println(statusText(StatusDone))
Helper functions
Here is what your program will look like once you move the status-to-text conversion into a separate function statusText. Even if you don’t feel confident with functions yet, you can treat this code as a “black box: put in a status—get a string”.
package main
import "fmt"
type Status int
const (
StatusNew Status = iota
StatusInProgress
StatusDone
)
func statusText(s Status) string {
switch s {
case StatusNew:
return "new"
case StatusInProgress:
return "in_progress"
case StatusDone:
return "done"
default:
return "unknown"
}
}
func main() {
fmt.Println(statusText(StatusDone)) // done
}
Such “translators” show up constantly: “code → text”, “flag → yes/no”, “type → name”. Their value is that they centralize the rule: if you decide that StatusInProgress should print as "in-progress", you change it in one place.
A small note: comments with expected output are a useful habit for learning and self-checking.
4. The flags pattern in Go
A set of attributes in one number: each flag is a separate bit
For flags, the key idea is: each flag is a separate bit. That’s why flags are almost always defined as 1 << iota, not just iota.
If you define flags as 0, 1, 2, 3, that will be an enum, not flags (and you’ll set yourself up for a logical trap).
Let a task have attributes: Urgent, HasDeadline, Blocked. We’ll introduce a TaskFlags type based on uint and define flags as powers of two:
package main
import "fmt"
type TaskFlags uint
const (
FlagUrgent TaskFlags = 1 << iota
FlagHasDeadline
FlagBlocked
)
func main() {
flags := FlagUrgent | FlagBlocked
fmt.Println(flags&FlagHasDeadline != 0) // false
}
Two things are important here:
- enabling flags is done via | (OR): this means “add an attribute”;
- checking is done via & (AND) and comparison to zero: this means “does it intersect with the mask?”.
If you try to check a flag like this: flags == FlagUrgent, you break the meaning of flags. Because flags can contain multiple flags, and equality to a single flag will only be true in a special case (when exactly one flag is set).
Helper functions for flags: readability matters more than “I already remember &”
Bitwise operations are honest, but visually “spiky”: flags = flags &^ FlagBlocked looks like an incantation. To make the code read like text, people often create small helper functions: hasFlag, addFlag, removeFlag.
This is not “speeding up the program” (the compiler is fast anyway), it’s speeding up a human reading the code.
Minimal helper for checking:
package main
import "fmt"
type TaskFlags uint
const (
FlagUrgent TaskFlags = 1 << iota
FlagHasDeadline
FlagBlocked
)
func hasFlag(all, f TaskFlags) bool { return all&f != 0 }
func main() {
flags := FlagUrgent | FlagBlocked
fmt.Println(hasFlag(flags, FlagBlocked)) // true
}
Now if hasFlag(flags, FlagBlocked) reads fine even for someone who doesn’t keep bitwise operators in their head (for example, you on a Monday morning).
Human-readable output for flags: not “13”, but “urgent|blocked”
When you print flags as a number, you get something like 5 or 13. That’s honest, but it doesn’t explain which attributes are enabled. That’s why next to flags you often have a “string builder”: it converts a set of flags into a clear label.
We won’t do auto-generation or “smart magic”; we’ll assemble a string with simple if statements. Yes, it’s a bit of manual work, but it’s maximally transparent.
package main
import "fmt"
type TaskFlags uint
const (
FlagUrgent TaskFlags = 1 << iota
FlagHasDeadline
FlagBlocked
)
func flagsText(f TaskFlags) string {
out := ""
if f&FlagUrgent != 0 {
out += "urgent|"
}
if f&FlagHasDeadline != 0 {
out += "deadline|"
}
if f&FlagBlocked != 0 {
out += "blocked|"
}
if out == "" {
return "none"
}
return out[:len(out)-1] // remove the last '|'
}
func main() {
fmt.Println(flagsText(FlagUrgent | FlagBlocked)) // urgent|blocked
}
There’s a small detail here that looks a bit “tricky”: out[:len(out)-1]. We cut off the last delimiter "|". In larger programs you might handle this differently, but for a small example this approach demonstrates the idea of “building a string piece by piece” well.
5. Mini app: TaskInspector
Now let’s put everything together into one scenario. The user enters two numbers: statusCode and flagsCode. We convert them into Status and TaskFlags, then print the status text and the decoded flags.
This is not a “real task tracker” yet, but it’s already a useful building block: we learned not to keep meaning in our heads, but to keep it in the code—via constants and helper functions.
package main
import "fmt"
type Status int
type TaskFlags uint
const (
StatusNew Status = iota
StatusInProgress
StatusDone
)
const (
FlagUrgent TaskFlags = 1 << iota
FlagHasDeadline
FlagBlocked
)
func statusText(s Status) string {
switch s {
case StatusNew:
return "new"
case StatusInProgress:
return "in_progress"
case StatusDone:
return "done"
default:
return "unknown"
}
}
func flagsText(f TaskFlags) string {
out := ""
if f&FlagUrgent != 0 {
out += "urgent "
}
if f&FlagHasDeadline != 0 {
out += "deadline "
}
if f&FlagBlocked != 0 {
out += "blocked "
}
if out == "" {
return "none"
}
return out
}
func main() {
var statusCode int
var flagsCode uint
fmt.Scan(&statusCode, &flagsCode)
s := Status(statusCode)
f := TaskFlags(flagsCode)
fmt.Println(statusText(s)) // example: in_progress
fmt.Println(flagsText(f)) // example: urgent blocked
}
If you run this and enter, for example, 1 5, then 1 will be StatusInProgress, and 5 in binary is 101, meaning FlagUrgent (bit 0) + FlagBlocked (bit 2). And the program will print “in_progress” and “urgent blocked”.
6. Typical mistakes
Mistake #1: mixing up enums and flags and checking flags with ==.
This is one of the most common problems: a developer sees “named constants” and starts comparing everything with equality. But flags are a set, and the check must go through a mask: flags&FlagX != 0. Equality is appropriate only for enums, where the meaning is a single value.
Mistake #2: defining flags as iota instead of 1 << iota.
If you write FlagRead = iota, you get values 0, 1, 2, 3…, and it stops being a bit mask. This “almost works” until you start combining attributes, and then you suddenly discover that “3” does not mean what you thought it meant.
Mistake #3: printing enum/flags “as a number” and being surprised that nobody understands anything.
The computer understands that 2 means done, but the user, the QA engineer, and you yourself a week later need text. For enums, do a switch into a string; for flags, parse the flag set into a label. This is not decoration; it’s practical diagnostics.
Mistake #4: not having a default branch in the enum translator.
Even if “our data is always correct”, reality loves surprises. If you don’t handle an unknown value, you’ll get either an empty string, or weird output, or incorrect logic. default: return "unknown" is a simple safety net.
Mistake #5: writing bitwise logic “straight” across the whole codebase and not using helper functions where they improve readability.
Bitwise expressions are fine by themselves, but when they repeat, the code starts to look like a math exam. Small helper functions like hasFlag and string “translators” don’t add magic—they add meaningful names, which reduces the chance of mistakes.
GO TO FULL VERSION