Skip to content

MkUnion Value Proposition

What Are Sum Types?

In type theory, data types can be composed in two fundamental ways:

  • Product Types: Combine multiple values simultaneously (structs in Go, "AND" relationship)
  • Sum Types: Represent a choice between variants (missing in Go, "OR" relationship)

Sum types, also known as tagged unions or algebraic data types (ADTs), are a cornerstone of type-safe programming. They allow you to express "this OR that" relationships with compile-time guarantees that all possibilities are handled.

The Mathematical Inspiration

MkUnion draws inspiration from category theory and algebraic data types found in functional programming languages. While Go's type system doesn't natively support true sum types, MkUnion provides a practical simulation that captures their most useful properties.

Theoretical Background

In languages with native sum types, these constructs are mathematical coproducts with specific properties:

  • Sum Types: Represent "either/or" relationships between types
  • Pattern Matching: Provides exhaustive case handling
  • Type Safety: Compile-time guarantees about variant handling

MkUnion emulates these properties through code generation, creating Go interfaces and structs that approximate this behavior

Design Philosophy

MkUnion's design is inspired by algebraic principles, but implemented within Go's constraints:

Conceptual goals (not formal properties):

  • Exhaustive handling: All variants must be handled
  • Type safety: No runtime type assertions in generated code
  • Zero-cost abstraction: Minimal runtime overhead
  • Composability: Unions can be nested and combined

What MkUnion provides:

  • ✓ Compile-time exhaustiveness checking (via function signatures)
  • ✓ Type-safe variant access (no manual casting)
  • ✓ Automatic JSON marshalling/unmarshalling
  • ✓ Generated helper functions

What MkUnion doesn't provide:

  • ✗ True algebraic data types (Go lacks the type system)
  • ✗ Mathematical properties like semiring laws
  • ✗ Zero-overhead (interface dispatch has cost)
  • ✗ Language-level integration

Practical Benefits

MkUnion enables Go developers to use patterns inspired by functional programming:

// Result type - explicit error handling without exceptions
//
//go:tag mkunion:"Result[A, E]"
type (
    Ok[A any, E any]  struct{ Value A }
    Err[A any, E any] struct{ Error E }
)


// Option type - represent nullable values explicitly
//
//go:tag mkunion:"Option[A]"
type (
    None[A any] struct{}
    Some[A any] struct{ Value A }
)


// These provide compile-time safety through exhaustive matching,
// though they're interface-based simulations, not true sum types

While languages like Haskell, Rust, and Swift have native sum types, MkUnion brings similar ergonomics to Go through code generation.

The Problem: Union Types in Go

Go is a powerful language, but it lacks native support for union types (also known as sum types or algebraic data types). This limitation leads developers to use workarounds that have significant drawbacks.

Traditional Go Approaches

1. The Visitor Pattern

type ShapeVisitor interface {
    VisitCircle(c *Circle)
    VisitRectangle(r *Rectangle)
    VisitTriangle(t *Triangle)
}

type Shape interface {
    Accept(v ShapeVisitor)
}

type Circle struct{ Radius float64 }
func (c *Circle) Accept(v ShapeVisitor) { v.VisitCircle(c) }

type Rectangle struct{ Width, Height float64 }
func (r *Rectangle) Accept(v ShapeVisitor) { v.VisitRectangle(r) }

type Triangle struct{ Base, Height float64 }
func (t *Triangle) Accept(v ShapeVisitor) { v.VisitTriangle(t) }

Characteristics:

  • Pro:
    • Actually provides compile-time exhaustiveness - adding a new method to the interface requires all implementations to be updated
    • Well-understood pattern in the software engineering community
    • No external dependencies or code generation
  • Con:
    • Verbose with boilerplate Accept methods
    • Handling return values requires additional interface methods

2. Iota and Switch Statements

example/iota.go
type IotaShapeType int

const (
    IotaCircleType IotaShapeType = iota
    IotaRectangleType
    IotaTriangleType
)

type (
    IotaCircle    struct{ Radius float64 }
    IotaRectangle struct{ Width, Height float64 }
    IotaTriangle  struct{ Base, Height float64 }
)

type IotaShape struct {
    Type     IotaShapeType
    Circle   *IotaCircle
    Rect     *IotaRectangle
    Triangle *IotaTriangle
}

func CalculateIotaArea(s IotaShape) float64 {
    switch s.Type {
    case IotaCircleType:
        if s.Circle != nil {
            return math.Pi * s.Circle.Radius * s.Circle.Radius
        }
    case IotaRectangleType:
        if s.Rect != nil {
            return s.Rect.Width * s.Rect.Height
        }
        // Missing IotaTriangleType will not tell you that it's missing switch case
        // you need to use tools like https://github.com/nishanths/exhaustive
    }
    return 0
}

Characteristics:

  • Pro:
    • Simple and straightforward
    • Familiar to C programmers
  • Con:
    • No compile-time exhaustiveness checking
    • Risk of nil pointer dereference if wrong field accessed
    • Requires careful coordination between type field and data fields
    • JSON marshalling requires custom implementation

Note

Projects like exhaustive with golangci-lint can detect non exhaustive switch situation when configured

3. Interface with Type Assertions

type Shape interface {
    shape() // private method to seal interface
}

type Circle struct{ Radius float64 }
func (Circle) shape() {}

func ProcessShape(s Shape) {
    switch v := s.(type) {
    case *Circle:
        // Process circle
    case *Rectangle:
        // Process rectangle
    default:
        // Handle unknown types gracefully
    }
}

Characteristics:

  • Pro:
    • Most idiomatic Go approach
    • Clean and readable
    • Works well with Go's interface philosophy
    • Static analysis tools can check exhaustiveness
  • Con:
    • No compiler-enforced exhaustiveness
    • Requires discipline to handle all cases
    • Each type needs custom JSON marshalling

How Other Languages Solve This

Let's see how languages with native sum types handle the same problem:

Rust:

enum Shape {
    Circle { radius: f64 },
    Rectangle { width: f64, height: f64 },
    Triangle { base: f64, height: f64 },
}

fn area(shape: &Shape) -> f64 {
    match shape {
        Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
        Shape::Rectangle { width, height } => width * height,
        Shape::Triangle { base, height } => 0.5 * base * height,
        // Compiler error if you miss a case!
    }
}

Haskell:

data Shape = Circle Double
           | Rectangle Double Double
           | Triangle Double Double

area :: Shape -> Double
area (Circle r) = pi * r * r
area (Rectangle w h) = w * h
area (Triangle b h) = 0.5 * b * h
-- Compiler warns about non-exhaustive patterns

Swift:

enum Shape {
    case circle(radius: Double)
    case rectangle(width: Double, height: Double)
    case triangle(base: Double, height: Double)
}

func area(of shape: Shape) -> Double {
    switch shape {
    case .circle(let radius):
        return .pi * radius * radius
    case .rectangle(let width, let height):
        return width * height
    case .triangle(let base, let height):
        return 0.5 * base * height
    // Compiler requires exhaustiveness
    }
}

TypeScript:

type Shape = 
    | { kind: 'circle'; radius: number }
    | { kind: 'rectangle'; width: number; height: number }
    | { kind: 'triangle'; base: number; height: number };

function area(shape: Shape): number {
    switch (shape.kind) {
        case 'circle':
            return Math.PI * shape.radius * shape.radius;
        case 'rectangle':
            return shape.width * shape.height;
        case 'triangle':
            return 0.5 * shape.base * shape.height;
        // TypeScript's exhaustiveness checking with strictNullChecks
    }
}

Notice the pattern? All these languages provide:

  • Compile-time exhaustiveness checking
  • Clean, readable syntax
  • Type-safe variant access
  • No runtime type assertions

While Go's simplicity is admirable, the lack of sum types forces developers into error-prone patterns that these other languages avoid entirely.

The MkUnion Approach

MkUnion generates strongly-typed union types with exhaustive pattern matching, addressing many of these challenges:

//go:tag mkunion:"Shape"
type (
    Circle struct{ Radius float64 }
    Rectangle struct{ Width, Height float64 }
    Triangle struct{ Base, Height float64 }
)

// Generated code provides:
area := MatchShapeR1(
    shape,
    func(c *Circle) float64 { return math.Pi * c.Radius * c.Radius },
    func(r *Rectangle) float64 { return r.Width * r.Height },
    func(t *Triangle) float64 { return 0.5 * t.Base * t.Height },
)

Key Benefits

1. Compile-Time Exhaustiveness Checking

The generated Match functions require you to handle every possible case through their function signature. Add a new type to the union? The compiler will force you to update all Match function calls.

// This won't compile if you miss a case!
result := MatchShapeR1(shape,
    func(c *Circle) string { return "circle" },
    func(r *Rectangle) string { return "rectangle" },
    // Compile error: missing Triangle handler
)

2. Reduced Boilerplate

Tag your types and run the generator. MkUnion creates:

  • Union interface with private discriminator
  • Constructor functions
  • Multiple match function variants (R0, R1, R2 for different return values)
  • Visitor pattern implementation
  • JSON marshalling/unmarshalling

3. Automatic JSON Marshalling

shape := &Circle{Radius: 10}
json, _ := shared.JSONMarshal[Shape](shape)
// {"$type":"example.Circle","example.Circle":{"Radius":10}}

decoded, _ := shared.JSONUnmarshal[Shape](json)
// Returns the correct concrete type

4. Generic Support

MkUnion fully supports Go generics:

// Result type - explicit error handling without exceptions
//
//go:tag mkunion:"Result[A, E]"
type (
    Ok[A any, E any]  struct{ Value A }
    Err[A any, E any] struct{ Error E }
)


// Use with any type
var result Result[string] = &Ok[string]{Value: "hello"}

5. TypeScript Generation

Generate TypeScript types for end-to-end type safety:

mkunion shape-export --language typescript --output-dir ./ts

Creates matching TypeScript discriminated unions that work seamlessly with your Go API.

Conclusion

MkUnion provides a code generation approach to simulating algebraic data types in Go. It offers compile-time exhaustiveness checking and automatic JSON marshalling at the cost of added build complexity and deviation from idiomatic Go.

Whether MkUnion is right for your project depends on your specific needs, team expertise, and tolerance for code generation. For teams comfortable with these trade-offs who need exhaustive pattern matching, MkUnion can be a valuable tool. For others, traditional Go patterns with modern linting tools may be more appropriate.