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:
- Type Parameters: The interface can have type parameters that constrain the input types
- Method Names: Method names can be anything descriptive
- Method Parameters: Each method must have the same number of parameters as type parameters
- Parameter Types: Parameters can be:
- Concrete types from the union (e.g.,
*Circle
,*Rectangle
) - The
any
type for wildcard matching - Other specific types for specialized matching
Examples
Example 1: Matching Shape Pairs
//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.
//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:
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
- Order Matters: The generated function checks patterns in the order they appear in the interface. Put more specific patterns first.
- Use Wildcards Wisely: The
any
type acts as a wildcard. Use it for catch-all cases or when you need to handle any type. - Exhaustiveness: Always include a catch-all pattern (typically with
any
parameters) to ensure all cases are handled. - Naming Conventions:
- Use descriptive names for match methods
- The interface name becomes the function prefix
- Consider the domain when naming
- Type Safety: The generated functions are fully type-safe and will panic if the patterns are not exhaustive.
Limitations
- Custom pattern matching functions are limited to matching on up to 3 return values (R0, R1, R2, R3).
- The interface methods must have parameters matching the type parameters in order.
- 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
- Union and generic types - Learn about generic unions
- Marshaling union in JSON - Learn about marshaling and unmarshalling of union types in JSON
- State Machines and unions - Learn about modeling state machines and how union type helps