Chris Dzombak

sharing preview • dzombak.com

Hacking slightly better sum types without Go generics

Go doesn't have real sum types, but we can at least hack together an exhaustive switch helper, without requiring generics.

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:

  1. 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.
  2. The signature of the generic switching function (reproduced below…) locks you into switch cases that return (something, error). This is less flexible than the standard switch 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-old switches, too, since it’s the path of least resistance.)
  3. 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:

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 panicing 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:

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.