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.
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:
- Don't try to genericize everything at once. Start with the hottest path — the one that pages you most. For us, that was the charge-and-capture flow.
- Generic code can be harder to debug with
delvein some edge cases. Keep your generic functions small and well-tested. - Constraint interfaces should model your domain, not your infrastructure.
ChargeableandRefundableare good constraints.SerializableandLoggableare not — those belong in separate utility generics. - Watch your compile times. Heavily generic code with deep type nesting can slow builds. We kept our type parameters to one or two levels deep and it stayed manageable.
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
- An Introduction to Generics — The Go Blog
- Tutorial: Getting started with generics — Go Documentation
- Type Parameter Declarations — The Go Programming Language Specification
- When To Use Generics — The Go Blog
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.