44.1 Go 1.18: Generics, Fuzzing, and the Workspace Mode
Alright, let’s talk about Go 1.18. This wasn’t just another annual update; this was the release where Go finally, finally got its act together on a feature we’d been yelling about for a decade: generics. It felt like waiting for a bus and then three show up at once, because they also threw in fuzzing and workspace mode. Let’s crack this thing open.
The Long-Awaited Generics (Type Parameters)
Let’s get the big one out of the way first. For years, writing a function to, say, find the maximum value in a slice of integers was a trivial func maxInt(a []int) int. Then you needed it for float64? Congrats, you got to write func maxFloat(a []float64) float64. This was absurd. We all just copied and pasted with different types, which is basically the programming equivalent of using a rock as a hammer.
Go 1.18 introduces type parameters to end this madness. The syntax uses square brackets [] before the function arguments. Don’t ask me why they didn’t reuse the already-familiar <> from other languages; the core team finds it ambiguous. I don’t agree, but it’s their language. You get used to it.
Here’s how you write a generic max function for any type that supports ordering (like >).
package main
import (
"fmt"
)
// Comparable is a constraint that allows any type that supports the > operator.
// We're using the new `comparable` built-in constraint and the standard `Ordered` constraint from the `cmp` package.
// Note: In early 1.18, you had to use `golang.org/x/exp/constraints`. Now, we have the more official `cmp` package.
import "cmp"
func max[T cmp.Ordered](a, b T) T {
if a > b {
return a
}
return b
}
func main() {
fmt.Println(max(10, 20)) // 20
fmt.Println(max(3.14, 1.59)) // 3.14
fmt.Println(max("apple", "zebra")) // zebra
}
The magic is [T cmp.Ordered]. It says “this function has a type parameter T that must satisfy the Ordered constraint.” A constraint is basically a set of allowed types. The built-in comparable constraint is for types that can use == and !=, while cmp.Ordered is for those that can also use <, <=, >, >=.
Why this way? The Go team is pathologically obsessed with simplicity and readability. They didn’t want the insane complexity of C++ templates. This implementation is deliberately less powerful; you can’t do template metaprogramming or specialize functions. The goal was to solve the 80% of boring copy-paste problems without making the compiler or your brain explode.
Pitfall #1: You can’t use the > operator on a generic T without a constraint that permits it. func foo[T any](a T) only allows operations valid for any type (basically, assignment and passing it around). This is why constraints are non-optional for useful generic functions.
Pitfall #2: You can’t instantiate a generic type with a type parameter directly using a built-in function like make. You need to instantiate the type first. This is clunky but necessary.
func createSlice[T any]() []T {
// return make([]T, 0) // This is what you *want* to do, but you can't.
var t T // Create a variable of type T.
return []T{t} // This works, or use a literal if T allows it.
// The better way is often to just return a nil slice: return nil
}
Fuzzing: Let the Computer Find Your Bugs
Fuzzing is a testing technique where you let a tool randomly mutates inputs to your code to find edge cases you’d never think of. Go 1.18 built it right into the go test tool. It’s fantastic for finding panics, especially on functions that parse or process complex inputs.
You write a fuzz test just like a unit test, but you use f.Fuzz instead of predefined inputs.
// file: mypackage_fuzz_test.go
package mypackage
import (
"testing"
"unicode/utf8"
)
func Reverse(s string) string {
r := []rune(s)
for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
// This is a normal unit test.
func TestReverse(t *testing.T) {
testcases := []struct {
in, want string
}{
{"Hello, world", "dlrow ,olleH"},
{" ", " "},
{"!12345", "54321!"},
}
for _, tc := range testcases {
rev := Reverse(tc.in)
if rev != tc.want {
t.Errorf("Reverse(%q) == %q, want %q", tc.in, rev, tc.want)
}
}
}
// This is a fuzz test.
func FuzzReverse(f *testing.F) {
// Seed corpus - good starting points for the fuzzer
f.Add("Hello, world!")
f.Add(" ")
f.Add("!12345")
f.Fuzz(func(t *testing.T, orig string) {
rev := Reverse(rev)
revAgain := Reverse(rev)
if orig != revAgain {
t.Errorf("Before: %q, after double reverse: %q", orig, revAgain)
}
if utf8.ValidString(orig) && !utf8.ValidString(rev) {
t.Errorf("Reverse produced invalid UTF-8 string %q from %q", rev, orig)
}
})
}
Run it with go test -fuzz=FuzzReverse. The fuzzer will quickly discover that our naive Reverse function breaks with complex Unicode strings (like those containing combining characters). This is the power of fuzzing: it finds the bizarre, non-obvious flaws by throwing random data at the wall.
Best Practice: Your fuzz target should be fast, deterministic, and should not have any state that persists between invocations. The goal is to run it millions of times.
The Go Workspace Mode: Taming the Multi-Module Chaos
Before 1.18, working on multiple interdependent modules was a pain. You’d have to constantly go workdir or use replace directives in your go.mod, which you’d then have to remember to remove before committing. It was a hack.
The go work command is the official solution. It creates a go.work file that sits above your modules and tells the go command to treat a set of directories as one single workspace.
# In your project root, which contains your main module and a library you're also developing
go work init
go work use ./my-cool-library
go work use ./my-main-app
This creates a go.work file:
go 1.18
use (
./my-cool-library
./my-main-app
)
Now, when you run go build in ./my-main-app, it will use the local, editable version of ./my-cool-library instead of the version tagged in its go.mod. It’s like a replace directive, but it’s local to your machine and doesn’t pollute the actual module definitions. You simply add go.work to your .gitignore and never commit it. It’s perfect for development.
Why it’s brilliant: It completely separates the published dependency graph (in go.mod) from your local development environment (in go.work). It removes a huge source of human error and finally makes developing large, multi-module repos a pleasant experience. It’s one of those features that, once you use it, you wonder how you ever lived without it.