How to Define Function Types in Go

Define Go function types using the func keyword with parameter and return types to create variables or aliases for function signatures.

When the loop needs to change its mind

You're building a notification service. The core loop is the same: fetch users, validate the payload, send the message. The sending part changes. Email goes to an SMTP server. SMS goes to a Twilio endpoint. Push notifications go to Firebase. You could write a massive switch statement inside your loop, but that couples your code to every transport method. Adding a new channel means touching the core loop. You want to hand the loop a function that knows how to send, and let the loop stay clean.

Go solves this with function types. Functions are first-class values in Go. You can store them in variables, pass them as arguments, and return them from other functions. A function type defines the shape of a function: what parameters it accepts and what it returns. Any function that matches that shape can fill the role.

Functions as values

Think of a function type as a job posting. The posting lists the requirements: "Must accept two integers. Must return an integer." Any function that matches those requirements can take the job. The type system checks the match at compile time. If the signatures don't line up, the compiler stops you.

Function types are defined using the func keyword followed by the parameter and return types. You can use this syntax to declare variables or to create named type aliases. Named types make code readable. type Validator func(User) error tells you exactly what the function does. type F func(User) error tells you nothing.

Here's the simplest way to define a function type and use it.

package main

// BinaryOp defines a function that takes two ints and returns an int.
// The name describes the behavior, not the syntax.
type BinaryOp func(int, int) int

func main() {
    // add matches BinaryOp: two ints in, one int out.
    // The compiler verifies the signature matches the type definition.
    var add BinaryOp = func(a, b int) int {
        return a + b
    }

    // Call the function stored in the variable.
    // The variable holds a pointer to the code and any captured state.
    result := add(3, 4)
    println(result)
}

Function types are shapes. Fill the shape with any function that fits.

Under the hood

When you write type BinaryOp func(int, int) int, you create a named type. The underlying type is still a function signature. The compiler treats BinaryOp and func(int, int) int as identical for assignment purposes. You can assign a lambda or a named function to a variable of this type.

At runtime, the variable holds a function value. This value contains a pointer to the executable code and, if the function is a closure, a reference to the captured variables. Calling the variable invokes the code. The type system guarantees the call site provides the right arguments. If you try to call add(3), the compiler rejects the program with not enough arguments in call to add.

Parameter names in the type definition are ignored. func(a int, b int) int is the same type as func(x int, y int) int. The compiler only cares about the types and their order. This allows you to write clean type definitions without cluttering them with parameter names.

Function types are not comparable with ==. You cannot check if two function values point to the same function. The compiler warns invalid operation: f == g (func can only be compared to nil). This is a deliberate design choice. Function values can capture environment state, and comparing them would require deep inspection of that state, which is expensive and often meaningless. You can compare a function value to nil to check if it has been assigned.

Real-world pattern: Retry logic

Function types shine when you need to abstract behavior without creating heavy objects. A retry wrapper is a common pattern. You pass an operation that might fail, and the wrapper handles the loop. The wrapper doesn't care what the operation does. It only cares that the operation returns an error.

Here's the type and the wrapper function.

package main

import (
    "fmt"
    "time"
)

// Operation defines a side-effect function that may return an error.
// It takes no arguments, making it easy to wrap any task.
type Operation func() error

// Retry calls op repeatedly until it succeeds or maxAttempts is reached.
// It sleeps 100ms between failures to avoid hammering the target.
func Retry(op Operation, maxAttempts int) error {
    for i := 0; i < maxAttempts; i++ {
        if err := op(); err == nil {
            return nil
        }
        time.Sleep(100 * time.Millisecond)
    }
    return fmt.Errorf("failed after %d attempts", maxAttempts)
}

Here's how you use the wrapper with a flaky task.

func main() {
    // Closure captures attempt counter to simulate transient failure.
    // The counter lives in the closure's environment.
    attempt := 0
    task := func() error {
        attempt++
        if attempt < 3 {
            return fmt.Errorf("temp error")
        }
        return nil
    }

    // Retry handles the loop; task provides the work.
    // The wrapper calls task() three times before it succeeds.
    if err := Retry(task, 5); err != nil {
        fmt.Println(err)
    }
}

Abstraction without ceremony. Pass the work, not the worker.

Method values and bound receivers

You can extract a method from a struct and use it as a function value. This is called a method value. The receiver is bound to the method value, so you don't need to pass the receiver explicitly when calling it.

This is useful when you need to pass a method as a callback. The method value captures the receiver, creating a closure-like effect without explicit closure syntax.

Here's a method value in action.

package main

type Counter struct {
    count int
}

// Increment adds one to the counter.
// The receiver is a pointer, so the method modifies the struct.
func (c *Counter) Increment() {
    c.count++
}

func main() {
    c := &Counter{}

    // Bind the method to the receiver.
    // inc is a function value that calls c.Increment().
    // The receiver c is captured in the function value.
    inc := c.Increment

    inc()
    inc()
    println(c.count)
}

A method value is a function that remembers its home.

Variadic signatures

Function types can include variadic parameters. The ... must be in the last position. The type system treats the variadic parameter as a slice internally, but the syntax allows callers to pass individual arguments.

Here's a variadic function type.

package main

// SumFunc accepts any number of ints.
// The ellipsis must be the last parameter in the signature.
type SumFunc func(...int) int

func main() {
    sum := func(nums ...int) int {
        total := 0
        for _, n := range nums {
            total += n
        }
        return total
    }

    var s SumFunc = sum
    println(s(1, 2, 3))
}

The ellipsis belongs at the end. Put it anywhere else and the compiler rejects the type.

Pitfalls and compiler checks

The compiler enforces exact signature matches. Parameter names don't matter in the type definition, only types and order. Return types must match exactly. If you define type Op func(int) string and try to assign a function that returns error, the compiler rejects it with cannot use func literal (type func() error) as type Op in assignment. The error message tells you the mismatch.

If you declare a function variable but don't assign it, it's nil. Calling a nil function panics at runtime. The compiler won't catch this unless you use static analysis. Always check for nil or ensure assignment before call. The panic message is runtime error: invalid memory address or nil pointer dereference. This is a common bug in callback systems where the callback is optional.

Function types cannot be used as map keys. Maps require comparable keys, and function values are not comparable. The compiler rejects map[Operation]string with invalid map key type Operation. Use a string identifier or a struct with comparable fields if you need to index by behavior.

The compiler checks signatures, not names. Match the types, ignore the labels.

Conventions and style

Go convention favors simplicity. Name your function types with a noun or a verb-noun phrase that describes the action. Validator, Handler, Transformer. Avoid generic names like Func or Callback. The type name should make the signature obvious.

Public types start with a capital letter. Validator is exported. validator is private to the package. Follow the same rule for function types. Export the type only if other packages need to implement it.

Trust gofmt. The formatter runs on save and enforces consistent layout. Don't argue about indentation. The community accepts the boilerplate of if err != nil { return err } because it makes the unhappy path visible. Function types don't change this rule. If your function returns an error, handle it.

Function types are often used to define small interfaces. If a type has only one method, you can sometimes replace the interface with a function type for less boilerplate. The community accepts this trade-off when the single method captures the essence of the behavior. "Accept interfaces, return structs" is the mantra, but function types are a pragmatic exception for callbacks.

Name the behavior, not the syntax. A type called Validator tells you what it does.

When to use function types

Use a function type when you need to pass behavior as a parameter to abstract implementation details. Use an interface when the function needs to be part of a larger contract with multiple methods or when you want to mock the behavior in tests with a struct. Use a struct with a method when the function needs to maintain internal state that persists across calls. Use direct code when you don't need abstraction; direct calls are faster and easier to read.

Keep it simple. Abstract only when the behavior changes.

Where to go next