Skip to content
gohacks.dev
Basics

Result Types and Errors in Go Generics Code Compared

Build a Result[T] type with Go generics, see why methods cannot change the type, and compare it with the idiomatic value-and-error return for real code.

7 min read
Summarize in AI
Flat illustration of an editor window full of code.

Generics arrived in Go 1.18, and within a week somebody had written Result[T]. It is the natural reflex for anyone who has used Rust's Result, Swift's Result or an Either in a functional language: if a function can fail, return a value that is either a success or a failure, and combine them with map and flatMap. The question is whether that style fits Go, and whether it should replace the multiple return values the language already has.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article builds the type, shows where it helps, shows where the language pushes back, and ends with a recommendation. It follows reducing error boilerplate and belongs to the error handling guide. Everything ran on Go 1.25.6.

A Result type is easy to write

result.goGo
package main

// Result holds either a value or an error.
type Result[T any] struct {
	Value T
	Err   error
}

func Ok[T any](v T) Result[T]         { return Result[T]{Value: v} }
func Fail[T any](err error) Result[T] { return Result[T]{Err: err} }

// Unwrap returns the pair in the form the rest of Go expects.
func (r Result[T]) Unwrap() (T, error) { return r.Value, r.Err }

// Map applies f to a successful result and passes a failure through.
func Map[T, U any](r Result[T], f func(T) (U, error)) Result[U] {
	if r.Err != nil {
		return Fail[U](r.Err)
	}
	v, err := f(r.Value)
	if err != nil {
		return Fail[U](err)
	}
	return Ok(v)
}

Twenty lines. It does what the other languages' versions do, and the one function Map already exposes the main snag: it is a function, not a method. In Rust you write result.map(f).and_then(g) and read left to right. In Go, a method cannot declare a type parameter of its own, so func (r Result[T]) Map[U any](...) is a compile error, and anything that changes the type has to be a top-level function. The chain becomes Map(Map(r, f), g), read inside out.

Using it

main.goGo
package main

import (
	"fmt"
	"strconv"
)

func parse(s string) Result[int] {
	n, err := strconv.Atoi(s)
	if err != nil {
		return Fail[int](err)
	}
	return Ok(n)
}

func half(n int) (int, error) {
	if n%2 != 0 {
		return 0, fmt.Errorf("%d is odd", n)
	}
	return n / 2, nil
}

func describe(r Result[string]) string {
	if v, err := r.Unwrap(); err != nil {
		return "error: " + err.Error()
	} else {
		return "ok: " + v
	}
}

func main() {
	for _, in := range []string{"84", "7", "x"} {
		r := Map(
			Map(parse(in), half),
			func(n int) (string, error) { return strconv.Itoa(n) + "!", nil },
		)
		fmt.Println(describe(r))
	}
}
Text
ok: 42!
error: 7 is odd
error: strconv.Atoi: parsing "x": invalid syntax

It works, and the output reports ok: 42! for 84, an odd-number error for 7 and a parse error for x. Now compare it to the version nobody wrote a library for:

main.goGo
package main

import (
	"fmt"
	"strconv"
)

func half(n int) (int, error) {
	if n%2 != 0 {
		return 0, fmt.Errorf("%d is odd", n)
	}
	return n / 2, nil
}

func process(s string) (string, error) {
	n, err := strconv.Atoi(s)
	if err != nil {
		return "", err
	}
	h, err := half(n)
	if err != nil {
		return "", err
	}
	return strconv.Itoa(h) + "!", nil
}

func main() {
	for _, in := range []string{"84", "7", "x"} {
		if out, err := process(in); err != nil {
			fmt.Println("error:", err)
		} else {
			fmt.Println("ok:", out)
		}
	}
}
Text
ok: 42!
error: 7 is odd
error: strconv.Atoi: parsing "x": invalid syntax

The plain version is longer by the six lines of checks, and every one of them is a place where you could add fmt.Errorf("parse %q: %w", s, err) without ceremony, set a breakpoint, or log. In the Map version, adding context to the second step means writing a closure that wraps, and the stack of nested calls is harder to read than the sequence of statements. The result type saved lines and cost legibility and debuggability, in a language whose culture values both.

What a Result type cannot do

It is not that the type is wrong. It is that the surrounding ecosystem is built for the other shape.

The standard library speaks (T, error). os.Open, strconv.Atoi, json.Marshal and every other function you call return pairs. Using Result means adapting at each boundary, with a wrapper or a conversion function, and the adapter is the boilerplate you were trying to remove.

defer and named results work with errors. The patterns in named returns and defer assume a function returns an error variable. A function returning Result[T] has no such variable to assign.

Tools see errors. errcheck and go vet know about unchecked error results. A Result that nobody reads is invisible to them, and the failure disappears silently.

It forces a zero value. The Value field holds a zero T on failure, which is the same as in (T, error), but now it is a field you can read without looking at Err, and nothing stops a caller from doing so. A pair has the same weakness, but the convention of checking err first is universal.

No ? and no match. The other languages pair their result types with syntax that makes them pleasant: ? to propagate, pattern matching to take them apart. Go has neither, and the team has chosen not to add the first (the proposals). A result type without those is a container you must open by hand.

Where a Result type does help

There is a place where a pair is awkward, and a small result type is the right fix: when a value and its error must travel together through something that carries one thing at a time. A channel is the classic case. A worker pool sends outcomes to a collector, and each outcome is either a value or a failure:

main.goGo
package main

import (
	"errors"
	"fmt"
	"sort"
	"strconv"
)

type result[T any] struct {
	Index int
	Value T
	Err   error
}

func main() {
	inputs := []string{"10", "x", "30"}
	out := make(chan result[int], len(inputs))

	for i, s := range inputs {
		go func() {
			n, err := strconv.Atoi(s)
			out <- result[int]{Index: i, Value: n, Err: err}
		}()
	}

	var all []result[int]
	for range inputs {
		all = append(all, <-out)
	}
	sort.Slice(all, func(a, b int) bool { return all[a].Index < all[b].Index })

	var errs []error
	sum := 0
	for _, r := range all {
		if r.Err != nil {
			errs = append(errs, fmt.Errorf("input %d: %w", r.Index, r.Err))
			continue
		}
		sum += r.Value
	}
	fmt.Println(sum, errors.Join(errs...))
}
Text
40 input 1: strconv.Atoi: parsing "x": invalid syntax

The generic struct is just a typed envelope, with no Map and no pretence of being a monad. It saves declaring a new struct per channel element type, which is the honest benefit generics give here. The same applies to a slice of outcomes from a batch (collecting multiple errors) and to a future-like helper that returns its result later.

Streaming values that can fail: iter.Seq2

Go 1.23 added range-over-function iterators, and the standard answer for a sequence in which each element can fail is iter.Seq2[T, error]. The consumer loops with for v, err := range seq and handles both on each step, which keeps if err != nil as the idiom:

main.goGo
package main

import (
	"fmt"
	"iter"
	"strconv"
)

func parseAll(inputs []string) iter.Seq2[int, error] {
	return func(yield func(int, error) bool) {
		for _, s := range inputs {
			n, err := strconv.Atoi(s)
			if !yield(n, err) {
				return
			}
		}
	}
}

func main() {
	for n, err := range parseAll([]string{"1", "two", "3"}) {
		if err != nil {
			fmt.Println("skip:", err)
			continue
		}
		fmt.Println("got", n)
	}
}
Text
got 1
skip: strconv.Atoi: parsing "two": invalid syntax
got 3

The consumer decides per element whether to skip, stop or collect, and the standard idiom survives. This is the place where "result" semantics are built into the language's own types, and it is a better fit than a custom Result stream (range over func).

A note on performance

People sometimes assume a result type is slower or faster than a pair. For a small T there is no meaningful difference: (T, error) returns two values in registers, and Result[T] is a struct that the compiler also passes in registers when it is small. The cost shows up in the generic instantiation for large or pointer-shaped types, where the compiler may use dictionaries and the struct copies the whole value, but at that point your function is doing real work, and the choice should be made on readability. Don't pick either style for speed; measure first if a profile ever points at it.

Recommendation

Use (T, error) for functions. It composes with everything, it is what readers expect, and it keeps the failure path visible. Use a small generic envelope when you must move a value and an error through a channel or store a batch of outcomes. Use iter.Seq2[T, error] for sequences. Skip Map and FlatMap, because Go's method rules make them awkward and its culture finds them opaque. If you come from a language where results are the norm, give the pair a month: the checks you resent are the same places where wrapping, logging and recovery go, and the code you will be reading at 3 a.m. is better for them. The language specification's rules about method declarations explain why a method cannot have its own type parameters, and the Go blog's An Introduction to Generics shows what generics are meant for.

Also inGenerics
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.