The boilerplate trap
You are building a library that fetches data from multiple sources. You have FetchUser that returns a user and an error. You have FetchPost that returns a post and an error. You want to write a helper that logs every fetch, measures latency, and retries on failure. You write LogAndRetry for users. It works. Then you copy-paste it for posts. Then for comments. Then for settings. You have four nearly identical functions differing only by type. Copy-paste is a maintenance debt. You need a way to write the logic once and apply it to any type.
Go generics let you parameterize types. A generic Result type wraps a value of any type alongside an error, giving you a uniform return signature. You write the helper once. The compiler generates the typed versions for you.
A box for any value
Think of a Result[T] as a standardized delivery box. The box always has the same shape and handles. The courier knows exactly how to carry it, stack it, and inspect the label. Inside the box, the contents change. Sometimes it holds a laptop, sometimes a book, sometimes a note saying the item is out of stock. The box structure doesn't care what's inside. The caller knows how to open the box and check the contents regardless of the payload.
In Go, the box is a struct with a type parameter. The type parameter T is a placeholder the compiler fills in when you use the type. You define the box once. You instantiate it with Result[int], Result[User], or Result[[]byte]. The compiler ensures type safety at every use site.
Minimal Result type
Here is the skeleton of a generic Result type. It holds a value of type T and an error. The any constraint means T can be any type.
// Result wraps a value and an error for uniform handling.
type Result[T any] struct {
Value T // T is the type parameter, filled in by the caller
Error error // Error holds the failure reason, nil on success
}
// Fetch returns a Result containing the fetched value or an error.
func Fetch[T any](id int) Result[T] {
var zeroValue T // Zero value of T, used when no data is available
// Simulate fetch logic here
return Result[T]{Value: zeroValue, Error: nil}
}
The struct definition uses [T any] to declare the type parameter. any is an alias for interface{}, meaning no constraints. The Fetch function also declares [T any]. When you call Fetch[int](1), the compiler substitutes T with int throughout the function body. The return type becomes Result[int]. The variable zeroValue becomes an int initialized to 0.
How the compiler fills in the blanks
Generics in Go are monomorphized. The compiler generates a separate version of the function for each distinct type you use. If you call Fetch[int] and Fetch[string], the compiler produces two distinct functions in the binary. This preserves performance. There is no runtime type dispatch overhead. The generated code is as fast as hand-written code for each type.
Type inference often lets you skip the explicit type argument. If the compiler can deduce T from the arguments or the expected return type, you can write Fetch(1) instead of Fetch[int](1). In the Fetch example above, id is int, which doesn't help infer T. You must specify T explicitly or rely on the assignment context: var res Result[int] = Fetch(1) lets the compiler infer T from the left-hand side.
Go functions typically return (T, error). The community prefers this pattern because it keeps the error visible and avoids allocating a struct for every call. Result is useful for pipelines and channels, but multi-return is the default choice for most APIs. Use Result when the single-return pattern simplifies the design.
Real-world: A retry wrapper
Here is a realistic example. A retry wrapper takes a function that returns a Result[T] and retries it on failure. The wrapper works for any type T because the function signature captures the type parameter.
// WithRetry executes a function and retries on error up to maxAttempts times.
func WithRetry[T any](fn func() Result[T], maxAttempts int) Result[T] {
var lastErr error
for i := 0; i < maxAttempts; i++ {
res := fn() // Call the function, capturing the Result
if res.Error == nil {
return res // Return immediately on success
}
lastErr = res.Error // Save error for the final report
}
return Result[T]{Error: lastErr} // Return failure after exhausting attempts
}
The function WithRetry declares [T any]. The parameter fn has type func() Result[T]. This ties the return type of fn to the type parameter T. When you pass a function that returns Result[User], the compiler infers T as User. The return type of WithRetry becomes Result[User]. The wrapper logic is generic. You can reuse it for users, posts, or any other type.
Receiver naming convention applies here too. The receiver for a method on Result is usually r, a short name matching the type. Avoid this or self. Go style favors brevity for receivers.
The zero-value trap
Generic types interact with Go's zero-value semantics. Every type in Go has a zero value. For integers, it is 0. For pointers, it is nil. For structs, it is a struct with all fields zeroed. When you create a Result[T] without setting Value, the Value field is the zero value of T.
This creates ambiguity. If T is int, a successful fetch might return 0. A failed fetch also returns 0 in the Value field. The caller must check the Error field to distinguish success from failure. If Error is nil, the result is success, even if Value is zero. If Error is not nil, the result is failure, and Value should be ignored.
The trap appears with pointer types. If T is *User, the zero value is nil. A successful fetch that finds no user might return nil. A failed fetch also returns nil. If the caller checks if res.Value == nil, they cannot tell if the user was not found or if the request failed. Always check the Error field first. The Value field is only meaningful when Error is nil.
Generics reduce repetition. They don't replace thought. Check the error. Always check the error.
Adding behavior with constraints
Sometimes you need to add methods to Result that depend on the type T. For example, you might want a Log method that prints the value. If T is an arbitrary type, you cannot print it meaningfully. You need to constrain T to types that support string conversion.
Go lets you constrain type parameters with interfaces. You can require T to implement a specific interface. If the constraint is not satisfied, the compiler rejects the code.
// Stringer is a standard library interface for types that can stringify themselves.
type Stringer interface {
String() string
}
// ResultStringer is a Result constrained to types that implement Stringer.
type ResultStringer[T Stringer] struct {
Value T
Error error
}
// Log prints the value or the error.
func (r ResultStringer[T]) Log() {
if r.Error != nil {
fmt.Printf("Error: %v\n", r.Error) // Print error message
} else {
fmt.Printf("Value: %s\n", r.Value.String()) // Call String method on T
}
}
The type ResultStringer[T Stringer] declares that T must satisfy the Stringer interface. The Log method calls r.Value.String(). The compiler knows T has a String method because of the constraint. If you try to instantiate ResultStringer[int], the compiler rejects the program with type int does not satisfy Stringer (missing method String). This error happens at compile time, preventing runtime panics.
Constraints are powerful. They let you write generic code that relies on specific behavior. Use them when the generic logic requires operations that not all types support. Without constraints, you are limited to operations available on any, which is very little.
If you try to compare a generic type to nil without constraints, the compiler rejects the code with invalid operation: cannot compare t with untyped nil (mismatched types T and untyped nil). The compiler cannot assume T is comparable to nil. Only pointers, interfaces, slices, maps, and channels can be compared to nil. If you need to compare T to nil, constrain T to a pointer type or an interface type.
When to use Result vs multi-return
Go has idiomatic patterns for error handling. Introducing a Result type changes the shape of your API. Choose the right tool for the context.
Use a generic Result[T] when you need to pass success and failure through a single channel or return value in a pipeline.
Use the standard multi-return (T, error) when writing library functions for the broader Go ecosystem.
Use a custom error type with errors.Is when you need rich error context without wrapping the success value.
Use a struct with methods when the result needs behavior that depends on the type parameter.
Multi-return is the default. It is simple, explicit, and familiar to every Go developer. Result shines in specific scenarios: channels that carry typed results, higher-order functions that manipulate results, or pipelines where a single value flows through stages. Don't force Result everywhere. Trust the idioms unless you have a clear reason to diverge.
Accept interfaces, return structs. This mantra guides Go design. Result[T] is a struct, so returning it aligns with the convention. The caller can embed it or wrap it without exposing your internal implementation. Keep the Result definition in the package that uses it. Export it only if external code needs to construct or inspect it.