go-fp in Production

One Generic Function to Rule Them All

7 min read

I wrote go-fp because Go's error handling was killing my code's readability. A few months later, it runs in production handling healthcare catalogue imports for Romania's national health system—crawling and updating data from the official sources.

This is not a library tutorial. This is what happens when you combine code generation with functional composition.

The Problem

Healthcare catalogues. Thirteen different XML types. For all 42 counties in Romania (41 counties + Bucharest). Each county publishes their own version—and each can contain their own errors and inconsistencies. It's tragic, really.

Each catalogue needs to be:

The traditional Go approach: 13 import functions × 42 counties worth of edge cases, each with its own error handling, each slightly different, each a maintenance nightmare.

113
.go files generated
13
catalogue types
1
generic import function

The XSD Pipeline

The Romanian national health system defines catalogues in XSD schemas. We generate Go structs from XSD. The compiler then checks everything.

XSD SchemaCode GeneratorGo StructsCompile-time Validation

A single catalogue type looks like this after generation:

type CataloguesAmbulance struct {
    XMLName   xml.Name `xml:"Catalogues"`
    IssueDate ROTime   `xml:"issueDate,attr"`

    Countries         []Country         `xml:"Countries>Country"`
    Districts         []District        `xml:"Districts>District"`
    Hospitals         []Hospital        `xml:"Hospitals>Hospital"`
    Physicians        []Physician       `xml:"Physicians>Physician"`
    Drugs             []Drug            `xml:"Drugs>Drug"`
    // ... 50+ more fields
}

Fifty-eight fields per catalogue. Thirteen catalogue types. All generated. All statically typed. The compiler catches schema mismatches before runtime.

Generated Validation

Each generated type gets a Validate() method that chains field validations:

func (ca CataloguesAmbulance) Validate() error {
    return chainValidations(
        func() error { return ca.IssueDate.Validate("catalogue ambulance", true) },
        func() error { return Countries{Country: ca.Countries}.Validate() },
        func() error { return Districts{District: ca.Districts}.Validate() },
        func() error { return Hospitals{Hospital: ca.Hospitals}.Validate() },
        // ... every field validated
    )
}

The validators are generated from XSD constraints and PDF documentation that gets updated whenever new legislation passes—sometimes overnight. String length limits, required fields, numeric precision—all compile-time checked. When the rules change, regenerate, recompile, redeploy. Minutes, not days.

func ValidateString(value, field string, required bool, maxLen int) error {
    if required && value == "" {
        return fmt.Errorf("%s is required", field)
    }
    if maxLen > 0 && len(value) > maxLen {
        return fmt.Errorf("%s exceeds max length %d", field, maxLen, len(value))
    }
    return nil
}

No runtime reflection. No string parsing. Just generated code that the compiler verifies.

Where go-fp Comes In

Generated code gives you types. But you still need to compose operations over those types. This is where go-fp turns 113 files into one generic function.

The Interface

Every generated catalogue type implements one interface:

type HasValidateAndSaveDB interface {
    Validate() error
    SaveToDB(ctx context.Context) error
}

That's it. Thirteen types. One contract. The interface tells you exactly what the system needs: validate your data, save yourself to the database.

The Chain

go-fp provides Chain[T], a monadic type that carries either a value or an error. No more if err != nil after every operation.

func DecodeXMLChain[T any](filePath string) chain.Chain[T] {
    return chain.LiftResult(func() (T, error) {
        var val T
        f, err := os.Open(filePath)
        if err != nil {
            return val, err
        }
        defer f.Close()
        err = xml.NewDecoder(f).Decode(&val)
        return val, err
    })
}

LiftResult takes a function that returns (T, error) and wraps it into a Chain[T]. The error is captured, not checked. It propagates automatically.

The Composition

Here's the entire import pipeline:

func ImportCatalogueChain[T HasValidateAndSaveDB](in CatalogueInput) chain.Chain[T] {
    fileChain := chain.Wrap(in.FilePath)
    xmlChain := chain.Bind(fileChain, DecodeXMLChain[T])
    validateChain := chain.Bind(xmlChain, ValidateChain[T])
    saveChain := chain.Bind(validateChain, func(cat T) chain.Chain[T] {
        return SaveToDBChain(in.Ctx, cat)
    })
    return saveChain
}

Read it top to bottom:

If any step fails, the chain short-circuits. No nested ifs. No error checks between steps. The composition handles it.

The Helpers

func ValidateChain[T HasValidateAndSaveDB](cat T) chain.Chain[T] {
    return chain.LiftResult(func() (T, error) {
        return cat, cat.Validate()
    })
}

func SaveToDBChain[T HasValidateAndSaveDB](ctx context.Context, cat T) chain.Chain[T] {
    return chain.LiftResult(func() (T, error) {
        return cat, cat.SaveToDB(ctx)
    })
}

Each helper is three lines. Each does one thing. Compose them freely.

The Dynamic Dispatch

Catalogue types are determined by filename. A map handles the dispatch:

var CatalogueConstructors = map[string]func(CatalogueInput) chain.Chain[any]{
    "medicaments":  wrapChain(ImportCatalogueChain[Medicaments]),
    "diagnostics":  wrapChain(ImportCatalogueChain[Diagnostics]),
    "procedures":   wrapChain(ImportCatalogueChain[Procedures]),
    // ... 10 more types
}

This is critical. If you pass interfaces around randomly in functions, you get runtime type checks. The compiler can't optimize. It's slow and fragile.

With the constructor map, each entry instantiates a concrete generic type at compile time. The compiler sees ImportCatalogueChain[Medicaments], not ImportCatalogueChain[interface{}]. It generates optimized, strongly-typed code for each catalogue type. The only runtime dispatch is the map lookup—one string comparison.

Add a new catalogue type: implement the interface, add one line to the map. Done. The compiler handles the rest.

The Execution

result := CatalogueConstructors[catalogueType](input)

chain.Match(result,
    func(cat any) {
        log.Printf("Imported %s successfully", catalogueType)
    },
    func(err error) {
        log.Printf("Failed to import %s: %v", catalogueType, err)
    },
)

Match forces you to handle both cases. Success or failure. No forgotten error checks.

Why This Matters

Code generation: XSD → Go structs → compile-time type safety

go-fp: Generic composition over all generated types

Result: 113 files, 13 catalogue types, 1 import function

The generated code gives you types. go-fp gives you composition. Together, they give you a system that scales without complexity.

Add a new catalogue type from the national health system:

  1. Run the generator on the new XSD
  2. Add one line to the constructor map
  3. Done. The generic function handles it.

The compiler catches type mismatches. go-fp catches error handling. Datasets are bounded in size and checked before processing. You write minimal code that is statically checked.

The Library

go-fp is minimal. The core primitives:

That's the entire API that matters. Everything else builds on these.

Get It

go get github.com/KeibiSoft/go-fp

MIT licensed. Production tested. 113 files worth of healthcare data imports prove it works.

GitHub: KeibiSoft/go-fp