How to Create a Type-Safe Enum with Structs in Go

Create a custom type and constants in Go to simulate type-safe enums and enforce valid values at compile time.

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.

Where to go next