How to Use Functions as First-Class Values in Go

Go treats functions as first-class values, allowing them to be assigned to variables, passed as arguments, and returned from other functions.

The retry loop problem

You are writing a database client. You need to retry queries when the connection drops, but you want to fail immediately if the query is invalid. You write a loop that catches errors, checks if they are retryable, waits, and tries again. It works.

Then you build an HTTP client. You need the same retry logic, but the retry condition is different: retry on 503, fail on 404. You copy the loop and change the condition. Then you add a file watcher that retries on permission errors. Now you have three nearly identical loops scattered across your codebase.

The duplication is the problem. The loop structure is the same; only the decision logic changes. You extract the loop into a helper function. You pass the work to do as one argument and the decision logic as another. Go lets you do this because functions are first-class values. You can assign them to variables, pass them as arguments, and return them from other functions, just like integers or strings.

Functions as values

In Go, a function is a value. The type of a function is determined by its parameter list and return list. func(int, int) int is a distinct type. func(string) error is another. You can declare variables of these types, store function references in them, and invoke them through the variable.

Think of a function value like a recipe card. The card describes the steps. You can keep the card in your pocket (assign to a variable), hand it to a chef to execute (pass as an argument), or write a new card based on the old one with a specific ingredient filled in (return a closure). The card is data, but it also describes behavior.

Go distinguishes between function declarations and function literals. A declaration gives a name to a function. A literal creates a function value inline, often capturing variables from the surrounding scope. Both result in values you can manipulate.

Minimal example

Here's the basics: assign a named function to a variable, pass a function as an argument, and return a new function from a factory.

package main

import "fmt"

// add takes two integers and returns their sum.
func add(a, b int) int {
	return a + b
}

// calculate accepts two integers and a function to operate on them.
func calculate(x, y int, op func(int, int) int) {
	// op is invoked with x and y.
	fmt.Println(op(x, y))
}

// makeMultiplier returns a function that multiplies its input by factor.
func makeMultiplier(factor int) func(int) int {
	// returns a closure that captures factor from the outer scope.
	return func(n int) int {
		return n * factor
	}
}

func main() {
	// op holds a reference to add. The type matches func(int, int) int.
	var op func(int, int) int = add
	fmt.Println(op(2, 3)) // prints: 5

	// calculate receives add as the third argument.
	calculate(5, 6, add) // prints: 11

	// makeMultiplier returns a new function bound to factor=3.
	triple := makeMultiplier(3)
	fmt.Println(triple(10)) // prints: 30
}

Functions are values. Treat them like data, but respect their execution side effects.

How function types work

When you write func(int, int) int, you are defining a type. Go does not require you to name every function type, but naming them improves readability in complex code. You can use a type declaration to create an alias.

// BinaryOp names a function type for clarity.
type BinaryOp func(int, int) int

// apply uses the named type instead of the inline signature.
func apply(op BinaryOp, x, y int) int {
	return op(x, y)
}

This makes signatures easier to read. apply(op BinaryOp, x, y int) is clearer than apply(op func(int, int) int, x, y int). The compiler treats BinaryOp and func(int, int) int as identical types for assignment and calling.

Go's type system enforces exact matches for function values. The parameter types and return types must align perfectly. Order matters. func(int, string) is not compatible with func(string, int). The compiler rejects mismatches with an error like cannot use fn (variable of type func(string, int)) as func(int, string) value in argument.

Convention aside: gofmt formats function definitions and signatures consistently. Braces go on the same line as the function keyword. Parameters are grouped by type when possible. Run gofmt and let the tool decide formatting. Argue logic, not indentation.

Closures and captured state

A function literal can access variables from the enclosing function. This is called a closure. The closure captures references to those variables, not copies of their values. The captured variables stay alive as long as the closure exists, even if the enclosing function has returned.

// counter returns a function that increments and returns a shared count.
func counter() func() int {
	// count lives on the heap because it is captured by the closure.
	count := 0
	return func() int {
		count++
		return count
	}
}

func main() {
	// nextCount holds the closure. count is hidden but accessible.
	nextCount := counter()
	fmt.Println(nextCount()) // prints: 1
	fmt.Println(nextCount()) // prints: 2
}

The variable count is not copied into the closure. The closure holds a pointer to count. Every call to nextCount modifies the same count. This is powerful for maintaining state without exposing it, but it requires care. If you capture a loop variable, all closures share the same variable, which leads to bugs.

Convention aside: If your function value needs to respect cancellation or deadlines, include context.Context as the first parameter in the signature. Functions that take a context should check ctx.Err() and respect timeouts. This applies to function values just as it does to named functions.

Realistic example: Retry with predicate

Here's a retry helper that accepts a task and a predicate to decide if an error is retryable. The predicate is a function value passed by the caller.

package main

import (
	"errors"
	"fmt"
	"time"
)

// Retry attempts task up to maxAttempts times.
// It continues only if shouldRetry returns true for the error.
func Retry(maxAttempts int, task func() error, shouldRetry func(error) bool) error {
	var lastErr error
	for attempt := 1; attempt <= maxAttempts; attempt++ {
		// task performs the work. It may return an error.
		err := task()
		if err == nil {
			return nil
		}
		lastErr = err
		// shouldRetry decides whether to loop again.
		if !shouldRetry(err) {
			return err
		}
		// wait before retrying, except after the last attempt.
		if attempt < maxAttempts {
			time.Sleep(time.Duration(attempt) * 100 * time.Millisecond)
		}
	}
	return fmt.Errorf("retry failed after %d attempts: %w", maxAttempts, lastErr)
}

func main() {
	// simulateTask mimics a flaky operation.
	attempts := 0
	simulateTask := func() error {
		attempts++
		if attempts < 3 {
			return errors.New("connection reset")
		}
		return nil
	}

	// retryableErr checks if the error message contains "reset".
	retryableErr := func(err error) bool {
		return err.Error() == "connection reset"
	}

	// Retry uses the task and predicate.
	err := Retry(5, simulateTask, retryableErr)
	if err != nil {
		fmt.Println(err)
	} else {
		fmt.Println("Success") // prints: Success
	}
}

The Retry function is generic in behavior. It doesn't know about connections or HTTP. It only knows how to loop and ask the predicate. The caller injects the specific logic via function values. This keeps the retry logic DRY and testable.

Convention aside: Functions often return errors. The pattern if err != nil { return err } is verbose by design. It makes the unhappy path visible. When returning a function value that returns an error, like func() error, the caller must handle the error. Do not swallow errors inside the function value unless you have a specific reason.

Method values

Go lets you extract a method from a receiver as a standalone function value. This is called a method value. You can assign a method to a variable, and calling the variable invokes the method on the bound receiver.

package main

import "fmt"

type Buffer struct {
	data []byte
}

// Write appends bytes to the buffer.
func (b *Buffer) Write(p []byte) (int, error) {
	b.data = append(b.data, p...)
	return len(p), nil
}

func main() {
	buf := &Buffer{}
	// writeFn binds the Write method to buf.
	// The type of writeFn is func([]byte) (int, error).
	writeFn := buf.Write

	// writeFn can be used anywhere a function of that type is expected.
	n, err := writeFn([]byte("hello"))
	fmt.Println(n, err) // prints: 5 <nil>
	fmt.Println(string(buf.data)) // prints: hello
}

Method values are useful when you need to adapt a method to a function signature. For example, a library might expect a func([]byte) (int, error) callback. If you have a struct with a Write method, you can pass buf.Write directly without wrapping it in a closure.

Closures capture variables by reference. Pass arguments to freeze the value.

Pitfalls and errors

Function values introduce specific failure modes. Knowing these prevents runtime panics and subtle bugs.

Loop variable capture. In loops, capturing the loop variable in a closure is a classic trap. Before Go 1.22, the compiler allowed this, but all closures shared the same variable, leading to unexpected values. Go 1.22 made this a hard error.

// This code fails to compile in Go 1.22+.
// The compiler rejects this with loop variable i captured by func literal.
for i := 0; i < 3; i++ {
	go func() {
		fmt.Println(i)
	}()
}

The fix is to pass the variable as an argument to the closure or shadow it. Passing an argument creates a copy for each iteration.

for i := 0; i < 3; i++ {
	// i is passed as an argument, creating a copy for this closure.
	go func(val int) {
		fmt.Println(val)
	}(i)
}

Nil functions. A function variable can be nil. Calling a nil function causes a panic. The runtime error is panic: runtime error: invalid memory address or nil pointer dereference. Always check for nil before calling a function value that might not be set.

var callback func()
// callback is nil.
if callback != nil {
	callback() // safe
}

Function comparison. Function values are not comparable. You cannot use == or != to compare two functions, even if they point to the same underlying code. The compiler rejects this with invalid operation: f1 == f2 (func can only be compared to nil). Functions are opaque. You can only compare them to nil.

Memory leaks. Closures capture variables. If a closure captures a large structure, that structure stays in memory as long as the closure exists. Be careful when returning closures from functions that process large data. The closure might hold a reference to a megabyte buffer when you only needed a small field. Pass the specific field to the closure instead of the whole structure.

Nil functions panic. Check before calling.

Decision matrix

Choosing between function values, interfaces, and structs depends on the complexity of the behavior and the need for state.

Use a function value when you need a single behavior without the overhead of defining a type and method. Function values are lightweight and expressive for callbacks, predicates, and simple adapters.

Use an interface when the behavior involves multiple methods or you want to mock the implementation in tests. Interfaces define contracts. A function value is a single method; an interface groups related methods.

Use a struct with methods when the behavior needs to hold state or belongs logically to a data type. Structs encapsulate data and behavior. If the function needs to maintain internal state across calls, a struct is often clearer than a closure.

Use a function literal when you need to capture local variables from the surrounding scope. Closures are ideal for short-lived adapters that depend on context-specific data.

Use a method value when you need to pass an existing method to a function expecting a function type. Method values avoid boilerplate wrappers and bind the receiver automatically.

Functions are values. Treat them like data, but respect their execution side effects.

Where to go next