Hacking slightly better sum types without Go generics
I wish Go had proper sum types. Go provides iota
to aid in declaring enum-style constants, and you can create a “type” for your “enumeration” with a declaration like type FieldSelected int
, but this is barely better than enums in C.
Among the problems with Go’s lack of proper first-class sum types: when you switch
over an enum variable, the compiler can’t warn you if you forget one of the cases. (Compare to something like Swift, which reports an error if you forget a case when switching over an enum.)
Lawrence Jones, in a blog post from March 13, suggests extracting the switch
statement into a separate function and abstracting its return type away using generics. (I’d recommend reading that post before the remainder of this one.)
There are a few problems here:
- There’s nothing to prevent other developers working on the same codebase from writing a plain old non-exhaustive
switch
statement. You’ll end up relying on code reviews or, in the best case, automated linting to enforce usage of the fancy new exhaustive switching function. - The signature of the generic switching function (reproduced below…) locks you into switch cases that return
(something, error)
. This is less flexible than the standardswitch
cases we’re replacing, and IMO it doesn’t scale to other use cases particularly well: what if your code needs to return multiple values, or none at all? (This inflexibility will encourage developers to write plain-oldswitch
es, too, since it’s the path of least resistance.) - The generic switching function conflates “your case function returned an error” with the “unhandled case” error. The former of these needs to be handled in the normal course of program execution; the latter is expected never to happen, and there’s nothing the caller could do to handle it. (It essentially represents a bug in the generic switching function’s implementation.)
Here’s the problematic switch function’s signature:
func CustomFieldTypeSwitch[R any](fieldType CustomFieldType,
option func(CustomFieldTypeSwitchOption) (R, error),
text func(CustomFieldTypeSwitchText) (R, error),
link func(CustomFieldTypeSwitchLink) (R, error),
numeric func(CustomFieldTypeSwitchNumeric) (R, error),
) (res R, err error)
My suggestions in this post will address concerns (2) and (3), but concern (1) is a fundamental artifact of Go’s lack of sum types.
Groundwork
The remainder of this post will use code samples to illustrate the issues at hand and demonstrate some solutions. Let’s start with some basic definitions, which will be used by all the following code samples:
type CustomFieldType string
const (
CustomFieldTypeOption CustomFieldType = "option"
CustomFieldTypeText CustomFieldType = "text"
CustomFieldTypeLink CustomFieldType = "link"
CustomFieldTypeNumeric CustomFieldType = "numeric"
)
// These types are used solely to help readability of the
// CustomFieldTypeSwitch helper.
type (
CustomFieldTypeSwitchOption CustomFieldType
CustomFieldTypeSwitchText CustomFieldType
CustomFieldTypeSwitchLink CustomFieldType
CustomFieldTypeSwitchNumeric CustomFieldType
)
Problem Illustration
In this code sample, using Lawrence’s original generic switch function, I just want to print a different message based on which type of custom field is passed in:
// CustomFieldTypeSwitch is a mechanism to switch over all possible
// values of a CustomFieldType, allowing the compiler to check all
// routes have been handled.
func CustomFieldTypeSwitch[R any](fieldType CustomFieldType,
option func(CustomFieldTypeSwitchOption) (R, error),
text func(CustomFieldTypeSwitchText) (R, error),
link func(CustomFieldTypeSwitchLink) (R, error),
numeric func(CustomFieldTypeSwitchNumeric) (R, error),
) (res R, err error) {
switch fieldType {
case CustomFieldTypeOption:
if option != nil {
return option("")
}
case CustomFieldTypeText:
if text != nil {
return text("")
}
case CustomFieldTypeLink:
if link != nil {
return link("")
}
case CustomFieldTypeNumeric:
if numeric != nil {
return numeric("")
}
default:
return res, fmt.Errorf("unsupported custom field type: '%s'", fieldType)
}
// If we get here, it's because we provided a nil function for a
// type of custom field, implying we don't want to handle it.
return res, nil
}
// printMessage prints a message depending on the type of the custom field
func printMessage(entry *CustomFieldEntry) {
_, _ = CustomFieldTypeSwitch(entry.CustomField.FieldType,
func(CustomFieldTypeSwitchOption) (int, error) {
fmt.Println("option")
return 0, nil
},
func(CustomFieldTypeSwitchText) (int, error) {
fmt.Println("text")
return 0, nil
},
func(CustomFieldTypeSwitchLink) (int, error) {
fmt.Println("link")
return 0, nil
},
func(CustomFieldTypeSwitchNumeric) (int, error) {
fmt.Println("numeric")
return 0, nil
},
)
}
Note that I have to give each “case” function in printMessage
a bogus return type, and the return values from the CustomFieldTypeSwitch
call are ignored entirely. This illustrates that the generic switch function’s signature only works in a fairly narrow use case. We’d run into the same issue if we wanted each “case” function to produce two values: there’s nowhere to return the second value. (You could package them together into a custom result structure, but that’s even more boilerplate and hence even more friction when people try to use this.)
This example also ends up ignoring the “unhandled case” error returned by CustomFieldTypeSwitch
if its developer forgot one of the enum cases. But, again, what is printMessage
to do at that point?
A Solution
We can alleviate these concerns by changing the custom switch function’s signature. What if it doesn’t force the case functions to return anything? The caller can declare zero or more result variables, as required, and instead allow the case functions (defined inline) to capture them:
// CustomFieldTypeSwitch is a mechanism to switch over all possible
// values of a CustomFieldType, allowing the compiler to check all
// routes have been handled.
func CustomFieldTypeSwitch(fieldType CustomFieldType,
option func(CustomFieldTypeSwitchOption),
text func(CustomFieldTypeSwitchText),
link func(CustomFieldTypeSwitchLink),
numeric func(CustomFieldTypeSwitchNumeric),
) error {
switch fieldType {
case CustomFieldTypeOption:
if option != nil {
option("")
}
case CustomFieldTypeText:
if text != nil {
text("")
}
case CustomFieldTypeLink:
if link != nil {
link("")
}
case CustomFieldTypeNumeric:
if numeric != nil {
numeric("")
}
default:
return fmt.Errorf("unsupported custom field type: '%s'", fieldType)
}
// If we get here, it's because we provided a nil function for a
// type of custom field, implying we don't want to handle it.
return nil
}
// printMessage prints a message depending on the type of the custom field
func printMessage(entry *CustomFieldEntry) {
err := CustomFieldTypeSwitch(entry.CustomField.FieldType,
func(CustomFieldTypeSwitchOption) {
fmt.Println("option")
},
func(CustomFieldTypeSwitchText) {
fmt.Println("text")
},
func(CustomFieldTypeSwitchLink) {
fmt.Println("link")
},
func(CustomFieldTypeSwitchNumeric) {
fmt.Println("numeric")
},
)
if err != nil {
panic(err)
}
}
This CustomFieldTypeSwitch
should be useful in a much wider range of use cases.
It doesn’t use generics; the only disadvantage there is that this blog post is therefore less likely to get clicks in the current environment of excitement surrounding Go 1.18. (Don’t get me wrong, I’m glad Go is finally gaining generics, but I don’t think this is a good use case for them.)
You’ll also notice that now the caller knows that the only possible error CustomFieldTypeSwitch
returns is the “programming error” case. In this case, printMessage
chooses to treat that as unrecoverable and panic
if it occurs.
Let’s consider Lawrence’s original use case, and swap out printMessage
for a function that produces a different value depending on the custom field type passed in:
// ConvertEntry returns a different *slack.InputBlock depending on the field type of the given custom field entry.
func ConvertEntry(entry *CustomFieldEntry) (result *slack.InputBlock, err error) {
if switchErr := CustomFieldTypeSwitch(entry.CustomField.FieldType,
func(CustomFieldTypeSwitchOption) {
result = selectCustomFieldToBlock(entry)
},
func(CustomFieldTypeSwitchText) {
// for this case, pretend the conversion failed, illustrating error-handling pathway:
err = errors.New("building the block failed, oh no")
},
func(CustomFieldTypeSwitchLink) {
result = linkCustomFieldToBlock(entry)
},
func(CustomFieldTypeSwitchNumeric) {
result = numericCustomFieldToBlock(entry)
},
); switchErr != nil {
panic(switchErr)
}
if (err != nil) {
// log it, or do some other error handling here if desired
}
return result, err
}
A few things to note about this sample:
- It still fulfills the requirements of the original use case
- Error handling is still easily possible if the “case” function produces an error
- The “unhandled case” error is returned and handled separately from any error produced by a “case” function, allowing the caller to handle those cases separately
The Default Case
I’d be happy to stop here. But I think the “unhandled case” error, in the CustomFieldTypeSwitch
function, is a programming error which we don’t expect a caller to be able to recover from. The entire point of this exercise is to centralize and contain the risk inherent in Go’s switch
statement, and I don’t think failing quietly is a good option there.
We can further simplify this function by panic
ing immediately if this error occurs:
// CustomFieldTypeSwitch is a mechanism to switch over all possible
// values of a CustomFieldType, allowing the compiler to check all
// routes have been handled.
func CustomFieldTypeSwitch(fieldType CustomFieldType,
option func(CustomFieldTypeSwitchOption),
text func(CustomFieldTypeSwitchText),
link func(CustomFieldTypeSwitchLink),
numeric func(CustomFieldTypeSwitchNumeric),
) {
switch fieldType {
case CustomFieldTypeOption:
if option != nil {
option("")
}
case CustomFieldTypeText:
if text != nil {
text("")
}
case CustomFieldTypeLink:
if link != nil {
link("")
}
case CustomFieldTypeNumeric:
if numeric != nil {
numeric("")
}
default:
panic(fmt.Sprintf("unsupported custom field type: '%s'", fieldType))
}
}
// ConvertEntry returns a value based on the field type of the given custom field entry.
// For this example it's just a string, but it could be something else entirely.
func ConvertEntry(entry *CustomFieldEntry) (result string, err error) {
CustomFieldTypeSwitch(entry.CustomField.FieldType,
func(CustomFieldTypeSwitchOption) {
result = "option"
},
func(CustomFieldTypeSwitchText) {
// for this case, pretend the conversion failed:
err = errors.New("building the string failed, oh no")
},
func(CustomFieldTypeSwitchLink) {
result = "link"
},
func(CustomFieldTypeSwitchNumeric) {
result = "numeric"
},
)
return result, err
}
Note this further simplifies the caller’s code; ConvertEntry
now reads almost like it uses a standard switch
statement, with the guarantee that it handles each enum case as expected.
It’s reasonable to disagree with this particular error-handling approach; it depends heavily on the specifics of your application and your use case. But it’s worth consideration, at least.
Conclusions
I don’t think this is really a good solution:
- It helps alleviate a single problem with Go’s lack of proper sum types: the lack of exhaustiveness checking for
switch
statements. Proper sum types in Go would provide a number of other niceties, not least of which is thatswitch
exhaustiveness would be checked at compile time. (Notice that, even if wepanic
in the default case, this technique still results in a runtime error.) - It doesn’t even alleviate that particularly well, because other developers can and likely will write plain old
switch
statements. As noted above, you could try to prevent that with a custom linter rule, but … that seems like an inordinate amount of work for the small advantage this technique gives you.
So, in closing, this is not something I’m likely to use in my programs. I just wanted to note that, though Go now has a generics-shaped hammer, every problem you see in your Go programs is not necessarily a generics-shaped nail.