Preventing Case-Mismatch Bugs with Go’s Type System
While working on a work-related task, I came across a pretty annoying bug that I was able to fix using Go's type system. The details of the task are irrelevant for the bug itself, but it basically involves building a pipeline that indexes products found by a deep research agent.
At a certain point in the pipeline, we pass a list of product names into an enrichment service that returns data with which to further enrich the product data before it moves along in the pipeline. The matching function looked something like this:
type ProductName string func (e *Enrichment) Run(ctx context.Context, researchedProducts []ResearchedProduct) ([]EnrichedProduct, error) { enrichedProducts := make([]EnrichedProduct, 0, len(researchedProducts)) nameToResearchedProductMap := lo.SliceToMap(researchedProducts, func(p ResearchedProduct) (ProductName, ResearchedProduct) { return p.Name, p }) productNames := lo.Keys(nameToResearchedProductMap) searchResults, err := e.productSearcher.Search(ctx, productNames) if err != nil { return nil, fmt.Errorf("searching service for products: %w", err) } for _, res := range searchResults { researchedProduct, ok := nameToResearchedProductMap[res.ProductName] if !ok { slog.WarnContext(ctx, "Failed to match product", "product_name", res.ProductName) continue } enrichedProducts = append(enrichedProducts, enrichResearchedProduct(researchedProduct, res.Product)) } return enrichedProducts, nil }
So we're getting a list of researched products, creating a map of
researchedProduct.Name -> researchedProduct and then passing the keys
of that map to the productSearcher so that it can find the enrichment
data for each product. Simple enough right? Well, while running this I
noticed that most products were falling through the cracks and were
not getting enriched, and the problem was easy to spot: by logging the
keys of the map and the product names returned by
e.productSearcher.Search I got the following:
- keys: ["Product One", "Product Two", "Product Three"]
- search results: ["product one", "product two", "product three"]
AHA! So at some point, when searching for extra data with which to
enrich our researched products, the product names became
normalized/lowercased and now don't match the existing map keys
anymore. A simple strings.ToLower would fix this. But why stop there?
We can use types to fix this bug for good!
Let's start by defining a new type that represents a normalized
product name, and have SearchResult.ProductName be of that type:
type NormalizedProductName string
A distinct string type prevents some accidental mixing, but callers
can still write NormalizedProductName("Product One") without
normalizing anything. A struct with an unexported field lets us
centralize normalization in a constructor. Since a struct containing a
string is comparable, we can also use it as a map key.
type NormalizedProductName struct { value string } func NewNormalizedProductName(productName ProductName) NormalizedProductName { return NormalizedProductName{value: strings.TrimSpace(strings.ToLower(string(productName)))} } func (n NormalizedProductName) String() string { return n.value }
The benefit of using this struct over the new string type is
that value, being a private field, cannot be populated by an outside
package and can only be populated by using the
NewNormalizedProductName function which normalizes the value while
creating a new instance of the struct.
Now, productSearcher.Search returns a list of
type ProductSearchResult struct { ProductName NormalizedProductName ... }
Inside the searcher, we use the constructor when turning each returned product name into a search result:
result := ProductSearchResult{ ProductName: NewNormalizedProductName(ProductName(returnedName)), ... }
Here, returnedName is the name returned by the enrichment service and
product is its enrichment data. This ensures that search-result names
and the map keys below go through the same normalization function.
So now, our matching function looks like this:
func (e *Enrichment) Run(ctx context.Context, researchedProducts []ResearchedProduct) ([]EnrichedProduct, error) { enrichedProducts := make([]EnrichedProduct, 0, len(researchedProducts)) nameToResearchedProductMap := lo.SliceToMap(researchedProducts, func(p ResearchedProduct) (NormalizedProductName, ResearchedProduct) { return NewNormalizedProductName(p.Name), p }) productNames := lo.Map(researchedProducts, func(p ResearchedProduct, _ int) ProductName { return ProductName(p.Name) }) searchResults, err := e.productSearcher.Search(ctx, productNames) if err != nil { return nil, fmt.Errorf("searching service for products: %w", err) } for _, res := range searchResults { researchedProduct, ok := nameToResearchedProductMap[res.ProductName] if !ok { slog.WarnContext(ctx, "Failed to match product", "product_name", res.ProductName) continue } enrichedProducts = append(enrichedProducts, enrichResearchedProduct(researchedProduct, res.Product)) } return enrichedProducts, nil }
Normalization still happens at runtime, but the compiler prevents raw product names from being used as normalized map keys. Callers outside the type’s package use the constructor to turn raw names into normalized names, making this mistake harder to repeat.