Question

Idiomatic Enums in Go: Representing Fixed Values Like DNA Bases

goenumsgenetics

Question

I want to represent a simplified chromosome in Go. A chromosome consists of N bases, and each base can only be one of these values: A, C, T, or G.

I would like to enforce these allowed values using an enum-like type. Since Go does not have enums in the same way as some other languages, what is the most idiomatic way to model this in Go?

Short Answer

By the end of this page, you will understand how Go typically represents enum-like values using custom types and constants, usually with iota when sequential values are useful. You will also see how to model a fixed set of values such as DNA bases (A, C, T, G), how to print them nicely, how to validate them, and how this pattern is used in real Go codebases.

Concept

In Go, the idiomatic replacement for enums is usually:

  1. Define a custom named type
  2. Declare a fixed set of typed constants for that type

For example:

type Base int

const (
    A Base = iota
    C
    T
    G
)

This gives you a type named Base with a limited set of intended constant values.

Why Go uses this approach

Go keeps the language small and simple. Instead of adding a separate enum feature, it relies on:

  • named types
  • constants
  • methods
  • optional validation

This works well because you can still:

  • restrict your API to a specific type
  • attach methods like String()
  • validate input when needed
  • use switch statements clearly

Important limitation

Go's enum-like pattern does not prevent someone from creating an invalid value directly:

var b Base = 99

That is still valid Go syntax because Base is just a named type underneath. So the idiomatic approach is:

  • use typed constants to define valid values
  • expose constructors or parsing functions if validation matters
  • add methods such as Valid() or String() for safety and readability

For your chromosome example

A DNA base is a perfect use case for enum-like values because:

  • there is a small fixed set of valid options
  • the values have clear meaning
  • code becomes easier to read with names like A and C
  • functions can accept Base instead of raw strings or integers

This is more expressive than passing around plain string or byte values everywhere.

Mental Model

Think of a Go enum as a custom label system rather than a locked box.

  • The type is the category, like Base
  • The constants are the approved labels, like A, C, T, and G
  • Your code agrees to use only those labels

It is similar to creating a set of official badges for a building:

  • A, C, T, G are the official badges
  • the badge type is Base
  • someone could theoretically forge a fake badge, so you may still need validation at the entrance

That is how Go enums work: they communicate intent clearly, but validation is still your responsibility when values come from outside your program.

Take Quiz

Syntax and Examples

Basic enum-like pattern in Go

package main

import "fmt"

type Base int

const (
    A Base = iota
    C
    T
    G
)

func main() {
    var b Base = A
    fmt.Println(b)
}

This compiles, but printing b will show its numeric value unless you define a String() method.

Adding a String method

package main

import "fmt"

type Base int

const (
    A Base = iota
    C
    T
    G
)

func (b Base) String() string {
    switch b {
    case A:
        return "A"
    case C:
        return "C"
    case T:
        return "T"
    case G:
        return "G"
    default:
        return "Unknown"
    }
}

func main() {
    chromosome := []Base{A, C, T, G, A}
    for _, base := range chromosome {
        fmt.Print(base, " ")
    }
}

Output:

A C T G A 

Parsing external input safely

If values come from a file, user input, or API, validation matters.

package main

import (
    "fmt"
)

type Base int

const (
    A Base = iota
    C
    T
    G
)

func (b Base) String() string {
    switch b {
    case A:
        return "A"
    case C:
        return "C"
    case T:
        return "T"
    case G:
        return "G"
    default:
        return "Unknown"
    }
}

func ParseBase(s string) (Base, error) {
    switch s {
    case "A":
        return A, nil
    case "C":
        return C, nil
    case "T":
        return T, nil
    case "G":
        return G, nil
    default:
        return 0, fmt.Errorf("invalid base: %s", s)
    }
}

This is a common real-world pattern in Go: define constants, then provide parsing and validation helpers.

Alternative: use characters directly

Since DNA bases are naturally single characters, another practical option is:

type Base byte

const (
    A Base = 'A'
    C Base = 'C'
    T Base = 'T'
    G Base = 'G'
)

This can be very readable because the underlying value already matches the domain concept.

Step by Step Execution

Consider this example:

package main

import "fmt"

type Base byte

const (
    A Base = 'A'
    C Base = 'C'
    T Base = 'T'
    G Base = 'G'
)

func main() {
    chromosome := []Base{A, G, C}

    for _, base := range chromosome {
        fmt.Printf("%c\n", base)
    }
}

Here is what happens step by step:

  1. type Base byte

    • Creates a new named type called Base
    • Its underlying type is byte
  2. The constants are declared:

    • A has value 'A'
    • C has value 'C'
    • T has value 'T'
    • G has value 'G'
  3. chromosome := []Base{A, G, C}

    • Creates a slice containing three DNA bases
  4. The for loop iterates through each element

    • First iteration: base is A
    • Second iteration: base is G
    • Third iteration: base is C
  5. fmt.Printf("%c\n", base)

    • %c prints the byte as a character
    • Output becomes:
A
G
C

Why this version is nice

Using byte with character literals works especially well when:

  • the values naturally map to characters
  • you want compact storage
  • you often convert to or from strings

For this specific DNA example, type Base byte is often more practical than type Base int.

Real World Use Cases

Enum-like types in Go are commonly used anywhere a value must come from a small fixed set.

Common examples

  • Status values: Pending, Running, Failed, Completed
  • HTTP or job states: Queued, Processing, Done
  • User roles: Admin, Editor, Viewer
  • Configuration modes: Debug, Test, Production
  • Domain-specific symbols: DNA bases like A, C, T, G

In your chromosome case

You might use a Base type for:

  • storing a chromosome as []Base
  • validating incoming DNA data
  • counting how many times each base appears
  • comparing sequences
  • converting between text input and internal values

Example:

func CountBase(sequence []Base, target Base) int {
    count := 0
    for _, b := range sequence {
        if b == target {
            count++
        }
    }
    return count
}

This is clearer than using raw strings everywhere because target is explicitly a Base.

Take Quiz

Real Codebase Usage

In real Go projects, enum-like values are usually wrapped with a few supporting patterns.

1. Typed constants for domain meaning

type Status int

const (
    Pending Status = iota
    Running
    Failed
    Done
)

This makes function signatures clearer:

func SetStatus(s Status) {}

2. String methods for logging and debugging

Developers often implement String() so logs and printed output are readable.

func (s Status) String() string {
    switch s {
    case Pending:
        return "pending"
    case Running:
        return "running"
    case Failed:
        return "failed"
    case Done:
        return "done"
    default:
        return "unknown"
    }
}

3. Parsing and validation for external input

When values come from JSON, forms, environment variables, or APIs, developers validate them explicitly.

Patterns include:

  • ParseStatus(string) (Status, error)
  • Valid() bool
  • custom JSON marshal/unmarshal methods when necessary

4. Guard clauses

A common pattern is rejecting invalid enum values early.

func ProcessBase(b Base) error {
    switch b {
    case A, C, T, G:
        return nil
    default:
        return fmt.Errorf("invalid base")
    }
}

5. Choosing the underlying type carefully

Developers pick the underlying type based on usage:

  • int for general enum-like values
  • string when readability matters strongly
  • byte when the domain is naturally character-based

For DNA bases, byte is often a very natural fit.

Common Mistakes

1. Using plain strings everywhere

This works, but it loses type safety.

func SaveBase(b string) {}

Now any string is accepted:

SaveBase("banana")

Better:

type Base byte

2. Assuming Go enums are fully restricted

Beginners often expect this to be impossible:

var b Base = 99

But it is allowed. Go enum-like types are not closed sets enforced by the compiler.

How to avoid problems

  • validate external input
  • expose constructor or parser functions
  • use methods like Valid()

Example:

func (b Base) Valid() bool {
    switch b {
    case A, C, T, G:
        return true
    default:
        return false
    }
}

3. Printing numeric enums and expecting names

Broken expectation:

type Base int

const (
    A Base = iota
    C
    T
    G
)

fmt.Println(A) // prints 0, not A

Fix it with a String() method.

4. Overusing iota when literal values are clearer

For DNA bases, this may be less expressive:

const (
    A Base = iota
    C
    T
    G
)

Sometimes this is clearer:

const (
    A Base = 'A'
    C Base = 'C'
    T Base = 'T'
    G Base = 'G'
)

Choose the representation that best matches the domain.

5. Forgetting a default case in String()

If an invalid value appears, your program should still behave predictably.

func (b Base) String() string {
    switch b {
    case A:
        return "A"
    case C:
        return "C"
    case T:
        return "T"
    case G:
        return "G"
    default:
        return "Unknown"
    }
}

Comparisons

ApproachExampleProsConsGood for
Typed int constantstype Base int + iotaCommon Go pattern, easy to use in switchPrinted values are numeric unless String() is addedGeneral enum-like values
Typed byte constantstype Base byte + 'A'Natural for character data, compact, easy to printStill needs validation for invalid valuesDNA bases, tokens, ASCII symbols
Typed string constantstype Base string + "A"Very readable, easy to serializeSlightly more memory, less compactConfig values, APIs, readable text
Plain strings"A"Simple to start withNo type safety, easy to misuseQuick scripts, prototypes

iota vs explicit values

StyleExampleBest use
iotaA Base = iotaSequential internal enum values
Explicit literalsA Base = 'A'When the stored value should match the real-world symbol

For DNA bases, explicit character literals are often more idiomatic and practical than iota.

Take Quiz

Cheat Sheet

Enum-like pattern in Go

type MyType int

const (
    ValueA MyType = iota
    ValueB
    ValueC
)

For DNA bases

type Base byte

const (
    A Base = 'A'
    C Base = 'C'
    T Base = 'T'
    G Base = 'G'
)

Validation method

func (b Base) Valid() bool {
    switch b {
    case A, C, T, G:
        return true
    default:
        return false
    }
}

String method

func (b Base) String() string {
    return string(b)
}

Parse from string

func ParseBase(s string) (Base, error) {
    if len(s) != 1 {
        return 0, fmt.Errorf("invalid base: %s", s)
    }

    b := Base(s[0])
    if !b.Valid() {
        return 0, fmt.Errorf("invalid base: %s", s)
    }
    return b, nil
}

Key rule

Go does not have true restricted enums. Use:

  • a named type
  • typed constants
  • validation when needed

Good default choice

  • Use int + iota for general enums
  • Use byte or string when the domain value itself matters

FAQ

Is there a real enum type in Go?

No. Go usually uses custom named types with constants to represent enum-like values.

What is the most idiomatic way to emulate an enum in Go?

Define a named type and a fixed set of typed constants. Optionally add String() and validation methods.

Should I use iota for all enums in Go?

Not always. iota is great for sequential values, but explicit values are better when they reflect the real domain, such as 'A', 'C', 'T', and 'G'.

Is type Base byte better than type Base int for DNA bases?

Often yes, because DNA bases are naturally single characters and can be stored and printed conveniently.

Can invalid enum values still exist in Go?

Yes. A variable of a named type can still hold other underlying values unless you validate them.

How do I print enum values nicely in Go?

Implement a String() method, or use a character-based underlying type like byte when appropriate.

How should I validate enum values from user input?

Use a parsing function such as ParseBase(string) (Base, error) and reject anything outside the allowed set.

Take Quiz

Mini Project

Description

Build a small Go program that models a DNA sequence using an enum-like Base type. The project demonstrates how to define a fixed set of allowed values, validate input, and work with a slice of strongly typed bases instead of raw strings.

Goal

Create a program that parses a DNA string, stores it as []Base, validates every character, and counts how many times each base appears.

Requirements

  • Define a Base type for DNA values.
  • Create constants for A, C, T, and G.
  • Write a function to parse a string into a slice of Base values.
  • Reject invalid characters with a clear error.
  • Count and print the number of each base in the sequence.
Take Quiz

Keep learning

Related questions

View more