You're building a task tracker
You're writing a task manager. The Task struct has a Status field. You want to ensure that Status can only be Pending, Active, or Closed. In JavaScript, you might pass a string literal and hope the caller remembers the exact casing. In Go, you want the compiler to reject task.Status = "Active" if that's not a defined value. Go doesn't have an enum keyword. You get type safety by creating a custom type and binding constants to it.
The title mentions structs, but idiomatic Go avoids structs for enums. A struct adds heap allocation overhead and complexity for no gain when you just need a set of named values. Typed constants are the standard solution. They live on the stack, compile to efficient integers or strings, and give you the safety you want without the baggage.
A shaped key for a specific lock
Think of a custom type as a shaped key. An int is a master key that fits any integer lock. A Status type is a key cut to a specific shape. The metal inside might be identical to an int, but the compiler checks the shape before letting it pass. You define the type, then you define the constants that are valid keys for that lock. This prevents mixing up a Status with a Priority or a raw number, even if both are stored as integers under the hood.
When you define type Status int, you are telling the compiler that Status is a new type. It happens to have the same underlying representation as int, but it is distinct. The compiler will not allow you to assign an int to a Status without an explicit conversion. This is the core of type safety. You can have type Priority int and type Status int in the same package, and the compiler treats them as completely unrelated types.
The standard pattern
Here's the simplest implementation: define the type, use iota to generate values, and add a String method for readability.
package main
import "fmt"
// Status is a custom integer type for task states.
type Status int
const (
// iota starts at 0 and increments for each constant in the block.
StatusPending Status = iota
StatusActive
StatusClosed
)
// String returns the human-readable name of the status.
func (s Status) String() string {
switch s {
case StatusPending:
return "Pending"
case StatusActive:
return "Active"
case StatusClosed:
return "Closed"
default:
// Handles invalid integer values that aren't defined constants.
return "Unknown"
}
}
func main() {
var s Status = StatusActive
fmt.Println(s) // prints: Active
}
iota generates values. You define the meaning.
How the compiler enforces safety
When you compile this code, the compiler treats Status as separate from int. If you try s := 1, the build fails with cannot use 1 (untyped int constant) as Status value in assignment. You must write s := Status(1) to force the conversion. This explicit conversion is the safety net. It forces you to acknowledge that you are bridging two different types.
The iota keyword generates sequential integers starting from zero within a const block. StatusPending becomes 0, StatusActive becomes 1, and StatusClosed becomes 2. You don't need to repeat iota for every line; Go carries the increment forward. The String method implements the fmt.Stringer interface. When you pass a Status to fmt.Println, the package checks for a String method and calls it automatically. Without this method, printing would just show the number.
The compiler protects the type, not the value range.
Using enums in real code
Here's how the enum fits into a real struct with behavior. The enum becomes a field, and methods use the constants for logic.
package main
import "fmt"
// Task represents a unit of work with a status.
type Task struct {
Name string
Status Status
}
// CanEdit checks if the task status allows modifications.
func (t Task) CanEdit() bool {
// Only pending tasks can be edited.
return t.Status == StatusPending
}
func main() {
t := Task{Name: "Fix bug", Status: StatusActive}
fmt.Println(t.CanEdit()) // prints: false
}
Enums are data. Methods are behavior. Keep them separate.
The power of iota expressions
iota isn't limited to simple counting. It can be part of any constant expression. This is useful for bit flags, where each value represents a distinct bit in an integer.
package main
import "fmt"
// Permission represents a set of access rights using bit flags.
type Permission int
const (
// 1 << 0 is 1 (binary 001)
Read Permission = 1 << iota
// 1 << 1 is 2 (binary 010)
Write
// 1 << 2 is 4 (binary 100)
Execute
)
func main() {
// Combine flags using bitwise OR.
rw := Read | Write
fmt.Printf("%b\n", rw) // prints: 11
}
Bit flags are a special case. Use them for permissions, not states.
Strings versus integers
Integer enums are efficient, but they serialize to numbers. If you send a Task to JSON, the status becomes 1. Sometimes you need the string value to survive serialization. You can define the enum as a string type instead.
package main
import "fmt"
// StatusString is a string-based enum for JSON-friendly values.
type StatusString string
const (
StatusPending StatusString = "pending"
StatusActive StatusString = "active"
StatusClosed StatusString = "closed"
)
func main() {
s := StatusActive
fmt.Println(s) // prints: active
}
Strings survive JSON. Integers need custom marshaling.
Pitfalls and validation
The compiler prevents type mismatches, but it doesn't prevent invalid values. You can write s := Status(99) and the compiler accepts it. The String method handles this by returning "Unknown", but your logic might break if you assume the value is valid. If values come from external sources like databases or user input, you should validate them.
package main
// IsValid checks if the status is one of the defined constants.
func (s Status) IsValid() bool {
switch s {
case StatusPending, StatusActive, StatusClosed:
return true
default:
return false
}
}
Validate external inputs. Trust the compiler for internal logic.
Another common mistake is comparing against raw integers. The compiler rejects s == 1 with invalid operation: s == 1 (mismatched types Status and int). Always compare against the constant. Also, remember that iota resets in every const block. If you define constants in separate blocks, the sequence restarts. Group related constants together to keep the sequence intact.
Conventions and style
Go has strong conventions around enums. The receiver name for methods should be short, usually matching the type's first letter. (s Status) is standard. Avoid (this Status) or (self Status). The gofmt tool automatically aligns constants in a block, so don't fight the formatter. Let it handle the indentation.
Public constants start with a capital letter. If the enum is only used within the package, use lowercase constants like statusPending. This keeps the package's public API clean. The String method is the community standard for making types readable. Implementing it makes debugging and logging much easier.
When to use what
Use a typed integer constant when you need a small set of values with zero runtime overhead and want the compiler to catch type mismatches. Use a typed string constant when the values must survive serialization exactly as they appear, such as in database columns or JSON fields. Use an interface with struct implementations when each variant needs distinct behavior or carries different data payloads. Use plain sequential integers when performance is the absolute bottleneck and the values are purely internal indices.
Pick the tool that matches the constraint. Type safety beats convenience.