Robust enums in Go

Yet another article on the topic

In a programming situation an enum should provide:

Go does not support enums natively and some of the patterns developers use to handle enums in Go have drawbacks, as you can read in Safer Enums in Go. I've come to use a slug-based approach that is iterable and gets close enough to type-safety for practical purposes. In my view it gives a good balance of safety and implementation effort.

Slug-based

A slug refers to a string that is lowercase, contains no spaces (using - or _ instead), is human-readable, and safe to use in URLs, JSON, and databases. In Go it can be implemented like:

package job

// Status represents the lifecycle state of a job.
type Status string

// Possible status values for a job. 
const (
	StatusPending Status = "pending" // StatusPending means the job is waiting to be picked up by a handler.
	StatusCancel  Status = "cancel"  // StatusCancel means cancellation has been requested but not yet completed.

	StatusProcessing Status = "processing" // StatusProcessing means a handler is actively working on the job.
	StatusCompleted  Status = "completed"  // StatusCompleted means the job finished successfully.
	StatusCanceled   Status = "canceled"   // StatusCanceled means the job was successfully canceled.
	StatusFailed     Status = "failed"     // StatusFailed means the handler encountered an unrecoverable error.
)

Slugs are self-documenting and work equally well for logging and marshaling, something the Go iota approach does not give you. However, the above code will not let you iterate over all constant values and it is not type-safe.

Type-safe

The slug-based approach cannot be made type-safe. The following is possible:

// s should hold only defined status values,
// but the following assignment of an arbitrary value 
// will not raise a compiler complaint
var s = Status("arbitrary value")

To improve in this area, you can do:

var KnownStatuses = []Status{StatusPending, StatusCancel, StatusProcessing, StatusCompleted, StatusCanceled, StatusFailed}
var knownStatusesSet = MakeSet[Status](KnownStatuses)
// ParseStatus will parse s into a job status
func ParseStatus(s string) (Status, error) {
	status := Status(s)
	if _, ok := knownStatusesSet[status]; ok {
		return status, nil
	}
	return "", fmt.Errorf("unknown job status: %s", s)
}

ParseStatus will parse any string and ensure it can be parsed into a valid Status, or an error is raised. It is still required by the programmer to call ParseStatus when transforming a string into a Status, like when unmarshaling from a JSON value to a Status value, but in this situation you have to do something like s = Status("processing") regardless, so you may as well do s = job.ParseStatus("processing").

A possible enhancement for a validation when you do not control the parsing situation, because someone else is handing over a Status to you, is:

// IsValidStatus will return true if s is a known job status
func IsValidStatus(s Status) (bool, error) {
	if _, err := ParseStatus(string(s)); err != nil {
		return false, err
	}
	return true, nil
}

When using ParseStatus and IsValidStatus at key locations in the code, I think I get something that is close enough to type-safety, which allows to work in all other code locations with unguarded access to Status.

MakeSet is a helper function that will transform a slice into a set , and as such, it can be useful as well in other situations. Note that Go does not have a native set type; they are typically implemented as maps with empty struct values since an empty struct consumes no memory.

func MakeSet[T comparable](values []T) map[T]struct{} {
	m := make(map[T]struct{}, len(values))
	for _, v := range values {
		m[v] = struct{}{}
	}
	return m
}

Iterable

It is most straightforward to iterate over KnownStatuses:

for s := range KnownStatuses {
	// do something with s
}

When the KnownStatuses should not be exposed outside of the job package, you can make a range-over-func (you need Go 1.23+ for this to work):

// keep knownStatus inside of the job package
var knownStatuses = []Status{StatusPending, StatusCancel, StatusProcessing, StatusCompleted, StatusCanceled, StatusFailed}
// AllStatuses is a range-over-func that 
// exposes an iterator over all job status values
func AllStatuses(yield func(Status) bool) {
    for _, s := range knownStatuses {
        if !yield(s) {
            return
        }
    }
}

Outside of the job package the range-over-func allows to iterate over all job status values without exposing the knownStatuses slice.

for s := range job.AllStatuses {
    fmt.Println(s)
}