Bringing Pattern Matching to Go
In the realm of software development, the concept of declarative code branching emerges as a powerful approach to managing complexity and conditional logic. Declarative branching shifts the focus from intricate, imperative if-else constructs to a more expressive and intuitive representation.
While we should minimize the excessive use of if-else constructs, some conditionals stem from unavoidable business requirements. In programming, we may need to create various pathways for processing different types of inputs.
The Problem with native conditionals
Consider an e-commerce platform that processes orders. Depending on the shipping location of a customer, the system would need to calculate different shipping costs. This involves defining distinct flows in the code to accommodate these input variations.
Let's look at the following example:
package main
// Interface that all strategy will implement
type ShippingStrategy interface {
CalculateCost() int
}
// Information on an order
type Order struct {
Country string
Distance int
Weight int
Volume int
}
const (
MY = "Malaysia"
AU = "Australia"
US = "US"
CN = "China"
)
// Returning a strategy that can be used to calculate cost
// Implementation detail of each strategy is not shown for simplicity
func shippingStrategyFactory(o Order) ShippingStrategy {
switch o.Country {
case MY:
return NewLocalStrategy(o.Distance, o.Weight)
case US, AU, CN:
if o.Volume > 100 || o.Weight > 250 {
return NewFreightStrategy(o.Distance, o.Weight, o.Volume)
}
return NewAirStrategy(o.Distance, o.Weight, o.Volume)
default:
return NewDefaultStrategy()
}
}
While we managed to abstract implementation details of calculating the cost of shipping into each of the strategies, we're still left with conditionals that determine which strategy should be used. As software grows, these conditionals will get complex and harder to maintain.
While there are already other pattern-matching libraries in Go, such as go-pattern-match by alexpantyukhin and go_matchable by kranfix, I believe that their APIs are incomplete for my specific use cases.
Introducing go-pattern-match
This library is inspired by ts-pattern, a pattern-matching library in the TypeScript realm. The library proved useful when I worked on a Foreign Exchange Engine, which required intricate conditionals to determine the optimal strategy for currency conversion.
In general, pattern-matching allows you to match patterns and execute a specific code based on the matched patterns. This allows us to do declarative code branching.
Rewriting the same conditionals from above, here is what we get:
package main
import (
"github.com/phakornkiong/go-pattern-match/pattern"
)
type ShippingStrategy interface {
CalculateCost() int
}
type Order struct {
Country string
Distance int
Weight int
Volume int
}
const (
MY = "Malaysia"
AU = "Australia"
US = "US"
CN = "China"
)
var LargeVolumePattern = pattern.Int().Gt(100)
var LargeWeightPattern = pattern.Int().Gt(250)
var IsOverseasPattern = pattern.Union[string](US, AU, CN)
var airPattern = pattern.Struct().
FieldPattern("Country", IsOverseasPattern)
var freightVolPattern = pattern.Struct().
FieldPattern("Country", IsOverseasPattern).
FieldPattern("Volume", LargeVolumePattern)
var freightWeightPattern = pattern.Struct().
FieldPattern("Country", IsOverseasPattern).
FieldPattern("Weight", LargeWeightPattern)
var freightPattern = pattern.UnionPattern(
freightVolPattern,
freightWeightPattern,
)
func shippingStrategyFactoryPattern(o Order) ShippingStrategy {
return pattern.NewMatcher[ShippingStrategy](o).
WithPattern(
pattern.Struct().FieldValue("Country", MY),
func() ShippingStrategy {
return NewLocalStrategy(o.Distance, o.Weight)
},
).
WithPattern(
freightPattern,
func() ShippingStrategy {
return NewFreightStrategy(
o.Distance,
o.Weight,
o.Volume,
)
},
).
WithPattern(
airPattern,
func() ShippingStrategy {
return NewAirStrategy(o.Distance, o.Weight, o.Volume)
},
).
Otherwise(
func() ShippingStrategy { return NewDefaultStrategy() },
)
}
Although the code is quite verbose, it encourages the composition of patterns, making it simpler to understand and facilitating unit testing. Now, you can test individual patterns directly by calling pattern.Match()
, which returns a boolean indicating whether the pattern has been matched.
Another example of what this library supports.
package main
import (
"fmt"
"github.com/phakornkiong/go-pattern-match/pattern"
)
func match(input []int) string {
return pattern.NewMatcher[string](input).
WithValues(
[]any{1, 2, 3, 4},
func() string { return "Nope" },
).
WithValues(
[]any{
pattern.Any(),
pattern.Not(36),
pattern.Union[int](99, 98),
255,
},
func() string { return "Its a match" },
).
Otherwise(func() string { return "Otherwise" })
}
func main() {
fmt.Println(match([]int{1, 2, 3, 4})) // "Nope"
fmt.Println(match([]int{25, 35, 99, 255})) // "Its a match"
fmt.Println(match([]int{1, 5, 6, 7})) // "Otherwise"
}
There are several convenient patterns, such as Slice
, Map
, String
, Int
, and many more, are implemented for ease of use.
Furthermore, users can easily extend the library by creating their custom patterns. This can be done by defining a new struct that implements the Patterner
interface.
Read more about the API on the go-pattern-match repository
Conclusion
In my humble opinion, I believe pattern-matching is great because it promotes the culture of writing composable and testable conditionals. It goes without saying that not all conditionals require pattern-matching, as simpler scenarios can often be effectively handled using traditional if-else or switch statements.
However, for more complex scenarios involving multiple conditions, data structures, and diverse patterns, pattern-matching offers a more elegant and readable approach, reducing the nesting of statements and enabling developers to express their intent more clearly.
Subscribe to my newsletter
Read articles from Phakorn Kiong directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by