Intro
Go has no enum or union keywords, so it relies on existing language features to achieve the same effect. By looking at other languages like TypeScript, we can study how to recreate these features.
Using TypeScript as an Example
Enum
Enum is commonly used to express a set of constants. For more details, see: enum, const enum, as const - Enumeration in TypeScript
enum ApiStatus { Idle, Loading, Success, Error}Union
In TypeScript, union types are typically used to express a set of types:
type ApiState = | { type: 'idle' } | { type: 'loading' } | { type: 'success'; data: Book[] } | { type: 'error'; message: string };Using Go as an Example
Enum
Go has no enum keyword. The idiomatic approach is to combine iota with a custom type to simulate an Enum:
type ApiStatus int
const ( Idle ApiStatus = iota Loading Success Error)iota starts at 0 and increments sequentially, so Idle = 0, Loading = 1, and so on. If you want the Enum values to be more readable, you can also use a string type instead:
type ApiStatus string
const ( Idle ApiStatus = "idle" Loading ApiStatus = "loading" Success ApiStatus = "success" Error ApiStatus = "error")func handleStatus(status ApiStatus) { switch status { case Idle: fmt.Println("Not started") case Loading: fmt.Println("Loading...") case Success: fmt.Println("Loaded successfully") case Error: fmt.Println("An error occurred") }}Union
Go has no native Union type. The idiomatic approach is to simulate it using an interface combined with structs, where each state is defined as its own struct implementing the same interface:
type ApiState interface { apiState()}
type IdleState struct{}type LoadingState struct{}type SuccessState struct{ Data []Book }type ErrorState struct{ Message string }
func (IdleState) apiState() {}func (LoadingState) apiState() {}func (SuccessState) apiState() {}func (ErrorState) apiState() {}When using it, a type switch is used to handle the different states:
func handle(state ApiState) { switch s := state.(type) { case IdleState: fmt.Println("idle") case LoadingState: fmt.Println("loading") case SuccessState: fmt.Printf("success: %v\n", s.Data) case ErrorState: fmt.Printf("error: %s\n", s.Message) }}The advantage of this pattern is type safety, and each state can carry its own data (such as SuccessState’s Data or ErrorState’s Message). This is conceptually close to TypeScript’s Discriminated Union, or in other words, it’s one way of achieving a Sum Type.
Summary
- Enum combination (
const+iota): used for scenarios involving “pure labels with no associated data” (e.g., the days of the week, or a list of related numeric values). - Union combination (
interface+struct): used for scenarios where “different states need to carry different data” (e.g., success and failure results from an API, or different input contexts).- Unlike TypeScript, Go’s interfaces are implemented implicitly — any type that implements the corresponding methods automatically becomes an implementation of that interface. This means the compiler has no way of knowing the full set of possible types. As a result, Go cannot perform exhaustiveness checking the way TypeScript can; the compiler won’t force developers to handle every state in a type switch. So when a new state is added, an existing type switch might silently miss that type without producing a compile error.
// One way is to explicitly catch unknown typesdefault:panic("unreachable")
Further Reading
- Implementing Discriminated Unions in Go: A TypeScript Perspective - Daniel Schmidt’s Blog
- TypeScript Enum’s vs Discriminated Unions - Software, Fitness, and Gaming – Jesse Warden