4

How does one achieve type-safe enums over a limited range of values in Go?

For example, suppose I wanted to model t-shirts with a simple Size and Color, both with limited possible values, ensuring that I could not create an instance with unsupported values.

I could declare the type Size and Color based on string, define enums for the valid values, and a TShirt type that uses them:

type Size string
const (
  Small  Size = "sm"
  Medium      = "md"
  Large       = "lg"
  // ...
)

type Color string
const (
  Red   Color = "r"
  Green       = "g"
  Blue        = "b"
  // ...
)

type TShirt struct {
  Size  Size
  Color Color
}

var mediumBlueShirt = TShirt{Medium, Blue}

But how could I ensure that no TShirt with undefined size/color is created?

var doNotWant = TShirt{"OutrageouslyLarge", "ImpossibleColor"}
maerics
  • 151,642
  • 46
  • 269
  • 291
  • There's no builtin language support for that. You could however un-export the fields and implement setter methods that validate the input before setting the field. I haven't given this much thought though, there may be a much better approach that's widely accepted. – mkopriva Nov 11 '21 at 18:59
  • 1
    You probably have already seen this https://stackoverflow.com/questions/14426366/what-is-an-idiomatic-way-of-representing-enums-in-go . The difference with the proposed duplicate is exactly the type safety. Declaring enums with predefined underlying types as `int` or `string` has the assignability problem. Interface enums can be embedded to circumvent type constraints. – blackgreen Nov 11 '21 at 19:29

2 Answers2

4

There is no direct support for limited set enum, however you can use the type system to achieve that:

// Exported interface 
type Size interface {
   // Unexported method
   isSize()
}

// unexported type
type size struct {
   sz string
}

func (s size) String() string {return s.sz}
// Implement the exported interface
func (size) isSize() {}

var (
  Small Size = size{"small"}
  Large Size = size{"large"}
)

This way you can only use predeclared values.

This is usually not worth the trouble. Consider validating such enums against a known valueset.

Burak Serdar
  • 46,455
  • 3
  • 40
  • 59
  • 2
    Not to be too pedantic but, if I import your package and embed the *exported* interface in my fake type it will implement it by default and I can then pass of my fake type as Size. Un-exporting the interface would avoid that as long as there isn't any other exported type in that package that implements it. But I'm with you on the last sentiment, it's not worth the trouble. – mkopriva Nov 11 '21 at 19:13
  • 1
    @mkopriva, good point on embedding the interface. You would need an exported type though to define other types or funcs in other packages using the enum. I wonder if there is a way to hack that up. – Burak Serdar Nov 11 '21 at 19:16
2

If you make your enums based on integer, you can create a pseudo value which can be used to check validity:

type Size int
const (
    Small Size = iota
    Medium
    Large
    maxSize // Always keep this at the end
)

var sizeToString = map[Size]string{
    Small: "sm",
    Medium: "md",
    Large: "lg",
}

func (s Size) String() string {
    return sizeToString[s]
}

func (s Size) Valid() bool {
    return s > 0 && s < maxSize
}

If you keep the maxSize at the end of the const block your Valid function will always work, even after adding or removing enum values.

You can do the same for your second enum type and your composite type can also define a Valid functions which returns true if both enums are valid as well.

When I use this pattern I always include a unit test to make sure that every enum value has a string translation so I don't forget. You can do this by making a for loop from 0 to maxSize and check that you never get an empty string back from sizeToString

Dylan Reimerink
  • 5,874
  • 2
  • 15
  • 21