I sometimes need strict(-ish) enums in Go. This is how I usually build them.

When something can be modelled as “one of these mutually exclusive options”, I usually reach for an interface with a marker method in Go:

 1type SomeEnum interface {
 2	isSomeEnum()
 3}
 4
 5type AnEnumValue struct{}
 6func (AnEnumValue) isSomeEnum() {}
 7
 8type AnotherEnumValue struct{}
 9func (AnotherEnumValue) isSomeEnum() {}
10
11type EnumValueWithPayload struct{
12	A string
13	B int
14}
15
16func (EnumValueWithPayload) isSomeEnum() {}

By not exporting the marker method, we’re closing the set of types that implement SomeEnum to just those in the same package. Types defined outside won’t match the interface, even if they have a isSomeEnum() method defined in their own package:

 1// foo/foo.go
 2package foo
 3
 4type SomeEnum interface {
 5	isSomeEnum()
 6}
 7
 8type EnumMember struct {}
 9func (EnumMember) isSomeEnum() {}
10
11// bar/bar.go
12package bar
13
14import "foo"
15
16type Sneaky struct{}
17func (Sneaky) isSomeEnum() {}
18
19// won't compile: cannot use Sneaky{} (value of struct type Sneaky) as foo.SomeEnum value in variable declaration: Sneaky does not implement foo.SomeEnum (unexported method isSomeEnum)
20var _ foo.SomeEnum = Sneaky{}

This feels almost kinda roughly like algebaraic types (at least sum types), except that nil is always a valid interface value. This means that you’ll still need to check for validity, but you get things like “An enum of value XYZ implies that there’s a list of strings somewhere” for free:

1func (e SomeEnum) error {
2	switch e := e.(type) {
3	case EnumValueWithPayload:
4		// Freely use e.A and e.B here
5	case AnotherEnumValue:
6		// No need to check whether A and B are valid, they don't exist
7	}
8}

This is a rough approximation of “make invalid states unrepresentable”. Some edge values (such as nil) do still creep in and need to be accounted for, but a lot of the “A and B only exists if the enum is EnumValueWithPayload” type shenanigans don’t need to be explicitly checked for.