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.