Skip to content

Custom Pattern Matching

mkunion provides a powerful feature for creating custom pattern matching functions that can match on multiple union values simultaneously. This guide explains how to write and use custom pattern matching functions.

Overview

While mkunion automatically generates pattern matching functions for individual union types (like MatchTreeR1, MatchShapeR2, etc.), you can also define custom pattern matching functions that work across multiple values or implement specialized matching logic.

Custom pattern matching is useful when you need to: - Match on combinations of multiple union values - Implement domain-specific matching patterns - Create reusable matching logic for complex scenarios

When to Use Custom Pattern Matching

Matching Specific Combinations

Custom pattern matching is particularly valuable when you need to handle pairs of types but only care about certain combinations. If you need to match all possible combinations exhaustively, it's better to use nested Match<Type>R<N> functions.

Simplifying Complex Type Assertions

Without custom pattern matching, matching two union values requires nested type assertions:

// Without custom pattern matching - verbose and error-prone
if a, ok := v1.(*Circle); ok {
    if b, ok := v2.(*Rectangle); ok {
        // handle Circle-Rectangle combination
    } else if b, ok := v2.(*Square); ok {
        // handle Circle-Square combination
    }
}
// ... many more nested ifs

This approach has several disadvantages: - Difficult to maintain as the number of types grows - Easy to miss combinations - No compile-time exhaustiveness checking - Deeply nested code that's hard to read

Custom pattern matching solves these issues elegantly:

// With custom pattern matching - clean and maintainable
MatchShapesR1(v1, v2,
    func(a *Circle, b *Circle) string { 
        return "Two circles" 
    },
    func(a *Rectangle, b any) string { 
        return "Rectangle meets another shape" 
    },
    func(a any, b any) string { 
        return "Other combination" 
    },
)

Basic Syntax

To create a custom pattern matching function, use the //go:tag mkmatch annotation on an interface definition:

//go:tag mkmatch
type MatchShapes[A, B Shape] interface {
    MatchCircles(x, y *Circle)
    MatchRectangleAny(x *Rectangle, y any)
    Finally(x, y any)
}

This generates functions like MatchShapesR0, MatchShapesR1, MatchShapesR2, and MatchShapesR3 with 0 to 3 return values respectively.

Custom Naming

You can also provide the function name in the tag, and mkunion will use that instead of interface name:

//go:tag mkmatch:"MyShapeMatcher"
type MatchShapes[A, B Shape] interface {
    MatchCircles(x, y *Circle)
    MatchRectangleAny(x *Rectangle, y any)
    Finally(x, y any)
}

This generates MyShapeMatcherR0, MyShapeMatcherR1, etc.,

Interface Definition Rules

When defining a match interface:

  1. Type Parameters: The interface can have type parameters that constrain the input types
  2. Method Names: Method names can be anything descriptive
  3. Method Parameters: Each method must have the same number of parameters as type parameters
  4. Parameter Types: Parameters can be:
  5. Concrete types from the union (e.g., *Circle, *Rectangle)
  6. The any type for wildcard matching
  7. Other specific types for specialized matching

Examples

Example 1: Matching Shape Pairs

example/shape.go
//go:tag mkunion:"Shape"
type (
    Circle struct {
        Radius float64
    }
    Rectangle struct {
        Width  float64
        Height float64
    }
    Square struct {
        Side float64
    }
)


//go:tag mkmatch
type MatchShapes[A, B Shape] interface {
    MatchCircles(x, y *Circle)
    MatchRectangleAny(x *Rectangle, y any)
    Finally(x, y any)
}


func CompareShapes(x, y Shape) string {
    return MatchShapesR1(
        x, y,
        func(x0 *Circle, x1 *Circle) string {
            return fmt.Sprintf("Two circles with radii %.2f and %.2f", x0.Radius, x1.Radius)
        },
        func(x0 *Rectangle, x1 any) string {
            return fmt.Sprintf("Rectangle (%.2fx%.2f) meets %T", x0.Width, x0.Height, x1)
        },
        func(x0 any, x1 any) string {
            return fmt.Sprintf("Finally: %T meets %T", x0, x1)
        },
    )
}

Example 2: Matching Tree Nodes

Notice that CombineTreeValues function match against TreePair type parameters.

example/tree.go
//go:tag mkunion:"Tree[A]"
type (
    Branch[A any] struct{ L, R Tree[A] }
    Leaf[A any]   struct{ Value A }
)


//go:tag mkmatch:"TreePairMatch"
type TreePair[T0, T1 any] interface {
    MatchLeafs(*Leaf[int], *Leaf[int])
    MatchBranches(*Branch[int], any)
    MatchMixed(any, any)
}


// CombineTreeValues returns sum of tree nodes for int nodes, and for others returns 0
// function is safe to use with any time, even if it's not a Tree
func CombineTreeValues(a, b any) int {
    return TreePairMatchR1(
        a, b,
        func(x0 *Leaf[int], x1 *Leaf[int]) int {
            // Both are leaves - add their values
            return x0.Value + x1.Value
        },
        func(x0 *Branch[int], x1 any) int {
            // First is branch - return special value
            return CombineTreeValues(x0.L, x0.R) + 1
        },
        func(x0 any, x1 any) int {
            // Mixed types - return default
            return 0
        },
    )
}

Example 3: State Machine Transitions

Custom pattern matching is particularly useful for state machines:

example/transition.go
var (
    ErrAlreadyProcessing = errors.New("already processing")
    ErrInvalidTransition = errors.New("invalid transition")
)

//go:tag mkmatch
type TransitionMatch[S State, C Command] interface {
    ProcessingStart(*Processing, *StartCommand)
    ProcessingComplete(*Processing, *CompleteCommand)
    InitialStart(*Initial, *StartCommand)
    Default(State, Command)
}


func Transition(state State, cmd Command) (State, error) {
    return TransitionMatchR2(
        state, cmd,
        func(s *Processing, c *StartCommand) (State, error) {
            return nil, ErrAlreadyProcessing
        },
        func(s *Processing, c *CompleteCommand) (State, error) {
            return &Complete{Result: c.Result}, nil
        },
        func(s *Initial, c *StartCommand) (State, error) {
            return &Processing{ID: c.ID}, nil
        },
        func(s State, c Command) (State, error) {
            return nil, ErrInvalidTransition
        },
    )
}

Best Practices

  1. Order Matters: The generated function checks patterns in the order they appear in the interface. Put more specific patterns first.
  2. Use Wildcards Wisely: The any type acts as a wildcard. Use it for catch-all cases or when you need to handle any type.
  3. Exhaustiveness: Always include a catch-all pattern (typically with any parameters) to ensure all cases are handled.
  4. Naming Conventions:
    • Use descriptive names for match methods
    • The interface name becomes the function prefix
    • Consider the domain when naming
  5. Type Safety: The generated functions are fully type-safe and will panic if the patterns are not exhaustive.

Limitations

  1. Custom pattern matching functions are limited to matching on up to 3 return values (R0, R1, R2, R3).
  2. The interface methods must have parameters matching the type parameters in order.
  3. All methods in the interface must have the same number of parameters.

Summary

Custom pattern matching in mkunion provides a powerful way to handle complex matching scenarios while maintaining type safety. By defining interfaces with the //go:tag mkmatch:"" annotation, you can create specialized matching functions that work across multiple values and implement domain-specific logic.

This feature is particularly useful for:

  • State machine implementations
  • Complex data transformations
  • Multi-value comparisons
  • Domain-specific pattern matching logic

Combined with mkunion's automatic union type generation and standard pattern matching, custom pattern matching completes a comprehensive toolkit for working with algebraic data types in Go.

Next steps