How to Write a Generic Slice Utility Function in Go

Write a generic Go function using type parameters to slice any slice type safely and reuse the logic.

The problem with copying and pasting slice helpers

You write a helper to trim a list of database results. It works perfectly for []int. Two days later you need the same logic for []string. You copy the function, rename it, change the type signature, and run the tests. A week later you need it for []User. You paste it again. Your codebase now has three nearly identical functions that differ only by their type names.

This repetition happens because Go slices are typed at compile time. A []int is not compatible with []string, even though the underlying memory layout is identical. Before Go 1.18, developers worked around this with interface{} or any, which stripped away type safety and forced runtime type assertions. Generics solved the problem by letting you write the logic once and let the compiler stamp in the correct types later.

How type parameters actually work

A generic function declares a type parameter instead of a concrete type. The syntax looks like func Name[T any](s []T) []T. The T is a placeholder. When you call the function, the compiler replaces T with the actual type you passed. The function body stays the same. The generated machine code is type-safe and performs exactly like a hand-written version.

Think of a type parameter like a cookie cutter. The cutter defines the shape and the cutting motion. You press it into dough, and it produces a cookie. Press it into a different batch, and it produces another cookie with the exact same shape. The cutter doesn't care about the flour or sugar. It only cares about the geometry. Generics work the same way. The function defines the algorithm. The compiler supplies the concrete type.

The any constraint is an alias for interface{}. It tells the compiler that T can be literally any type. You can also use tighter constraints like comparable or custom interfaces to restrict what types are allowed. The constraint acts as a guard rail. It prevents callers from passing types that would break the logic.

Generics shift the work from runtime to compile time. You pay a small cost in compilation speed. You gain type safety, better IDE autocomplete, and zero runtime overhead.

Type parameters are templates. The compiler fills them in before your program ever runs.

A minimal generic slice function

Here is the simplest generic slice utility. It takes a slice, a start index, and an end index, and returns a new slice bounded to valid ranges. It also handles reversed arguments by swapping them.

// Slice returns a bounded sub-slice of s between start and end.
// It clamps out-of-bounds indices and swaps them if start > end.
func Slice[T any](s []T, start, end int) []T {
	// Clamp negative start to zero to avoid panic
	if start < 0 {
		start = 0
	}
	// Clamp end to slice length to prevent index out of range
	if end > len(s) {
		end = len(s)
	}
	// Swap indices if the caller accidentally reversed them
	if start > end {
		start, end = end, start
	}
	// Return the standard Go slice expression
	return s[start:end]
}

You call it with explicit type arguments or let the compiler infer them. Slice[int](nums, 1, 3) works. Slice(words, 0, 2) also works because Go infers T from the first slice argument. The function returns a []T that matches the input type exactly.

Go functions follow a naming convention where the doc comment starts with the function name. The receiver name in methods is usually one or two letters matching the type. Utility functions like this stand alone, so they get a capital letter to mark them as exported. Public names start with a capital letter. Private names start lowercase. The language uses visibility rules instead of keywords.

Keep the bounds logic tight. Slices are views, not copies.

What the compiler does behind the scenes

When you compile a program that uses generics, the Go compiler performs monomorphization. It looks at every call site, identifies the concrete type used for T, and generates a specialized version of the function for that type. If you call Slice[int] and Slice[string], the compiler produces two separate functions in the final binary. Each one operates on its own type. No type assertions. No interface boxing. No runtime dispatch.

This differs from languages that use type erasure. Type erasure replaces generic types with a common base type at runtime and inserts casts. Go avoids that entirely. The generated code is identical to what you would write by hand. The tradeoff is that the compiler has to do more work upfront. Large projects with heavy generic usage may see slightly longer build times. The runtime performance is unaffected.

Type inference follows strict rules. The compiler looks at the arguments you pass and tries to match them to the type parameters. If you pass a []int as the first argument, T becomes int. If you pass a []string, T becomes string. If you mix types in a way that creates ambiguity, the compiler rejects the call. You get an error like cannot infer T or type mismatch in argument. Explicit type arguments resolve ambiguity instantly.

Generics also interact with Go's slice mechanics. A slice is a three-field header: a pointer to the underlying array, a length, and a capacity. When you return s[start:end], you are creating a new header that points to the same backing array. Modifying the returned slice modifies the original data. This is intentional and efficient. It means your generic utility does not allocate memory for the elements. It only allocates the tiny slice header.

Monomorphization means zero runtime cost. The compiler does the heavy lifting so your program stays fast.

Putting it into a real codebase

Utilities shine when they fit into larger pipelines. Consider a service that fetches log lines from a buffer, filters them, and passes a window to a downstream processor. The log lines are strings, but the same windowing logic should work for metrics, events, or parsed JSON objects.

Here is how the generic function integrates into a realistic handler. The handler respects context cancellation, follows the standard error handling pattern, and passes the slice through the utility.

// FetchWindow retrieves a bounded slice of items from the store.
// It respects ctx cancellation and returns the windowed data.
func FetchWindow(ctx context.Context, store *DataStore, start, end int) ([]string, error) {
	// Check context before doing any work
	if err := ctx.Err(); err != nil {
		return nil, err
	}

	// Load all items from the backing store
	items, err := store.LoadAll(ctx)
	if err != nil {
		return nil, err
	}

	// Apply the generic slice utility to bound the result
	windowed := Slice(items, start, end)

	// Return the bounded slice and nil error
	return windowed, nil
}

The context.Context always goes as the first parameter, conventionally named ctx. Functions that accept a context should check ctx.Err() early and respect deadlines. The error handling follows the standard if err != nil { return err } pattern. The community accepts the boilerplate because it makes failure paths explicit. You never hide errors behind silent returns.

Notice that FetchWindow returns []string. The generic Slice function inferred T as string from the items argument. If store.LoadAll returned []Metric instead, the same handler would compile without changes. The type flows through automatically.

You can also pass function values as type parameters. Go allows func[T any](f func(T) bool) to create generic filters or mappers. The syntax stays the same. The compiler treats function types like any other type. You just need to match the signature exactly.

Context is plumbing. Run it through every long-lived call site.

Common traps and compiler reactions

Generics introduce a few patterns that trip up developers coming from dynamically typed languages. The compiler catches most mistakes early, but the error messages can feel verbose until you learn the patterns.

The first trap is assuming slices are copied. A slice header is small, but the backing array is shared. If you pass a windowed slice to a background goroutine and mutate it, you mutate the original data. This causes subtle race conditions. The compiler will not stop you. You must manage lifetimes yourself. Goroutine leaks happen when a goroutine waits on a channel that never gets closed. Always provide a cancellation path or use a buffered channel with a clear ownership boundary.

The second trap is overusing any. The any constraint accepts everything, including channels, functions, and maps. If your utility tries to compare elements with ==, the compiler rejects it with invalid operation: a == b (operator == not defined on T). You need the comparable constraint instead. The constraint system exists to prevent exactly this kind of runtime panic.

The third trap is ignoring type inference limits. If you write a function with two type parameters, func Pair[T, U any](a T, b U) (T, U), and call it with Pair(1, "hello"), inference works. If you call it with Pair(1, 2), the compiler cannot distinguish T from U without hints. You get cannot infer T and U. Explicit arguments fix it: Pair[int, int](1, 2).

The compiler also enforces strict visibility rules. You cannot use a generic type parameter to access unexported fields of a package-private struct. The error reads T does not satisfy required constraint or cannot refer to unexported name. Generics do not bypass Go's package boundaries. They operate within them.

Trust the constraint system. Let the compiler reject invalid types before they reach production.

When to reach for generics

Go favors simplicity. Generics are a tool, not a default. You should pick the right abstraction for the job.

Use a generic function when you need type-safe collection utilities that work across multiple types without runtime overhead. Use an interface when you need polymorphic behavior based on methods rather than type structure. Use any or interface{} when you are building a serialization layer or a configuration parser that genuinely accepts arbitrary payloads. Use code generation when you need to emit boilerplate for database mappers or protobuf wrappers. Use plain sequential code when you don't need concurrency or type abstraction: the simplest thing that works is usually the right thing.

Generics reduce duplication. They do not replace good design. Write the function once. Let the compiler handle the rest.

Where to go next