April 9, 2026 10 min read

Go Generics for Type-Safe Payment Processing — Eliminating Runtime Surprises

After one too many production panics caused by bad type assertions in our payment pipeline, I rewrote the core processing layer with Go generics. Here's what I learned and the patterns that stuck.

I still remember the 2 AM page. A merchant's refund batch failed silently because somewhere deep in our pipeline, an interface{} value was asserted to *CardPayment when it was actually a *BankTransfer. The type assertion didn't panic — we'd used the comma-ok idiom — but the nil result cascaded through three services before anyone noticed. Refunds sat in limbo for six hours.

That incident was the final push. We'd been eyeing Go generics since 1.18 landed, and our payment processing layer was the perfect candidate. Not because generics are a silver bullet, but because payment code is exactly the kind of domain where the compiler catching a wrong type at build time is worth more than any amount of runtime checking.

The Problem with interface{} in Payment Code

Our pre-generics codebase used interface{} everywhere. Payment instruments, transaction results, settlement records — all passed around as empty interfaces and asserted back at consumption points. It worked, technically. But it created a class of bugs that only surfaced under specific conditions: a particular payment method, a specific currency, a certain merchant configuration.

Aspect Before (interface{}) After (Generics)
Type errors caught At runtime, often in production At compile time, before deploy
Code readability Requires reading assertions to understand flow Types visible in function signatures
Refactoring safety Rename a field, find out in staging (maybe) Compiler flags every callsite immediately
IDE support Autocomplete stops at interface{} Full autocomplete through the pipeline
Test coverage needed Must test every type path explicitly Compiler covers type correctness; test logic
New payment method Add assertions in 12+ places, hope you got them all Implement the constraint interface, compiler guides you

That table isn't theoretical. It's a summary of what actually changed in our codebase over three months of migration.

Generic Result Types for Payment Operations

The first pattern we adopted was a generic Result type. Every payment operation — charge, refund, void, capture — returns either a typed success value or a structured error. No more returning (interface{}, error) and hoping the caller knows what to assert.

type Result[T any] struct {
    Value T
    Err   *PaymentError
}

func (r Result[T]) IsOK() bool {
    return r.Err == nil
}

func (r Result[T]) Unwrap() (T, error) {
    if r.Err != nil {
        var zero T
        return zero, r.Err
    }
    return r.Value, nil
}

// Now every operation has a clear return type
func ChargeCard(req CardChargeRequest) Result[CardChargeResponse] {
    // ...
}

func RefundTransfer(req BankRefundRequest) Result[BankRefundResponse] {
    // ...
}

This seems simple, and it is. That's the point. Before generics, our Charge function returned (PaymentResult, error) where PaymentResult had a Data interface{} field. Every consumer did a type switch. With the generic Result, the compiler knows exactly what Value is. No assertion needed, no switch statement, no runtime surprise.

Constraint Interfaces for Payment Instruments

The real power showed up when we defined constraint interfaces for payment instruments. Instead of a single PaymentMethod interface with methods that only half the implementations actually use, we built composable constraints.

type PaymentInstrument interface {
    InstrumentID() string
    InstrumentType() string
    Validate() error
}

type Chargeable interface {
    PaymentInstrument
    ChargeAmount(amount Money) Result[ChargeReceipt]
}

type Refundable interface {
    PaymentInstrument
    RefundAmount(amount Money, reason string) Result[RefundReceipt]
}

// Generic processor that works with any chargeable instrument
func ProcessCharge[T Chargeable](instrument T, amount Money) Result[ChargeReceipt] {
    if err := instrument.Validate(); err != nil {
        return Result[ChargeReceipt]{Err: &PaymentError{Code: "INVALID_INSTRUMENT", Msg: err.Error()}}
    }
    return instrument.ChargeAmount(amount)
}

When we added Apple Pay support last quarter, we implemented Chargeable on the new ApplePayToken struct and the compiler told us exactly which methods were missing. No grep-ing through the codebase for type switches to update. No forgotten assertion in an edge-case handler.

The Generic Payment Pipeline

Here's how these pieces fit together in our processing pipeline. Each stage is generic, and the types flow through from request to settlement.

ValidateT: PaymentInstrument
AuthorizeT: Chargeable
ProcessResult[ChargeReceipt]
StoreRepo[ChargeReceipt]
SettleResult[Settlement]

Each box is a generic function. The type parameter flows left to right, and the compiler verifies every handoff. If someone accidentally passes a BankTransfer (which implements Refundable but not Chargeable) into the charge pipeline, it fails at compile time — not at 2 AM in production.

Generic Repository Pattern for Transactions

We also applied generics to our storage layer. Instead of one massive TransactionRepository with methods that return interface{}, we have typed repositories.

type Repository[T any] interface {
    Store(ctx context.Context, entity T) error
    FindByID(ctx context.Context, id string) (T, error)
    FindByMerchant(ctx context.Context, merchantID string, opts QueryOpts) ([]T, error)
}

// Concrete implementations
type chargeRepo struct {
    db *sql.DB
}

func (r *chargeRepo) Store(ctx context.Context, receipt ChargeReceipt) error {
    // SQL insert with typed fields — no marshaling from interface{}
    _, err := r.db.ExecContext(ctx,
        "INSERT INTO charges (id, merchant_id, amount, currency, status) VALUES ($1,$2,$3,$4,$5)",
        receipt.ID, receipt.MerchantID, receipt.Amount.MinorUnits, receipt.Amount.Currency, receipt.Status,
    )
    return err
}

func NewChargeRepository(db *sql.DB) Repository[ChargeReceipt] {
    return &chargeRepo{db: db}
}

The Repository[ChargeReceipt] return type means every consumer gets full type safety. No casting, no assertion, no "what does this field actually contain" guessing games during code review.

Type-Safe Money Handling

One subtle but important use: we parameterized our money operations by currency to prevent accidental cross-currency arithmetic. This is a simplified version of what we run.

type CurrencyCode string

const (
    USD CurrencyCode = "USD"
    EUR CurrencyCode = "EUR"
    SGD CurrencyCode = "SGD"
)

type Money[C ~string] struct {
    MinorUnits int64
    Currency   C
}

func Add[C ~string](a, b Money[C]) Money[C] {
    return Money[C]{MinorUnits: a.MinorUnits + b.MinorUnits, Currency: a.Currency}
}

// This compiles:
// total := Add(usdAmount, usdAmount)

// This also compiles because both are CurrencyCode,
// so we add a runtime check too:
func SafeAdd[C comparable](a, b Money[C]) (Money[C], error) {
    if a.Currency != b.Currency {
        var zero Money[C]
        return zero, fmt.Errorf("currency mismatch: %v vs %v", a.Currency, b.Currency)
    }
    return Money[C]{MinorUnits: a.MinorUnits + b.MinorUnits, Currency: a.Currency}, nil
}

Generics don't fully solve the currency-mixing problem at compile time in Go (you'd need phantom types or dependent types for that), but they get us closer. The Money[C] type makes the currency a visible part of the signature, which catches mistakes during code review that Money alone wouldn't.

Key takeaway: Generics in payment code aren't about clever abstractions. They're about making illegal states unrepresentable at compile time. Every type assertion you remove from a payment pipeline is one fewer place where a runtime panic can hide. Start with Result[T] and constraint interfaces — those two patterns alone eliminated roughly 80% of our type-assertion-related bugs.

Practical Advice for Migration

If you're considering this for your own payment codebase, a few things I wish I'd known earlier:

Three months after the migration, our payment-related runtime panics dropped to zero. Not because generics are magic, but because the compiler now catches the exact class of bug that used to wake us up at night. That's a trade I'll take every time.

References

Disclaimer: This article reflects the author's personal experience and opinions. Product names, logos, and brands are property of their respective owners. Pricing and features mentioned are subject to change — always verify with official documentation.