Zero cost debug assertions in Go
Tags: go, programming, testing, showdev
Published: 2024-04-05 10:38:03

NOTE: You can find original post on my personal blog https://www.negrel.dev/blog/zero-cost-debug-assertions-in-go/index.html

While I was working on my very own
[TUI library](https://github.com/negrel/paon), I had this idea of bringing debug assertions to Go.

From [dart.dev](https://dart.dev/language/error-handling):

> During development, use an assert statement—
> `assert(<condition>, <optionalMessage>);` —to disrupt normal execution if a boolean condition is false.

> In production code, assertions are ignored, and the arguments to `assert` aren’t evaluated.

Many languages such as Rust, C and Zig have assertions built-ins, but Go doesn’t.

I don’t know the exact reason, maybe they’re not considered idiomatic but they’re definitely useful.

Assertions really shine when you want to enforce an invariant that can’t be encoded in the type system. They’re not mean to replace runtime errors but programming errors such as passing a nil pointer to a function that always expect a valid pointer.

## How are assertions implemented?

Typically, assertions are implemented using either **macros** or **conditional compilation**.

Macros are great as they can print friendly, easy to read failure descriptions with the failing expression and its location (line number). This is how [C assert works.](https://en.cppreference.com/w/cpp/error/assert)

```c
int even_num = 1;
assert(even_num % 2 == 0);
```

Will print:

```bash
Assertion `even_num % 2 == 0' failed.
Aborted
```

Another way is to use conditional compilation and include different `assert` implementation at build time. Go supports conditional compilation via build constraints. Similar to `//go:embed file.txt`, `//go:generate ...` and `//go:noinline` constraints, `//go:build ...` can be used to include or exclude content of a file depending on `go build` tags specified.

Building an assert like function is trivial:

```go
// debug.go
//go:build debug

package assert

func Assert(cond bool, message string) {
	if !cond {
		panic(message)
	}
}
```

```go
// prod.go
//go:build !debug
// Note the "!" before debug.

package assert

func Assert(_ bool, _ string) {
	// noop
}
```

If a program importing the above `assert` package is build using `go build -tags debug`, panicking assert implementation will be included.

Voilà! Our implementation is now fully functional.

## Going further…

While our simple implementation works, it sucks from a DX point of view as we can’t know asserted value unless it is specified in the message.

There are 2 ways to work around this limitation:

- [Generate code](https://go.dev/blog/generate) for each assertion
- Create multiples specialized assert functions for specific usage

Generating code is generally the recommended way to tackle this kind of problem.
Nevertheless, it adds complexity to the build process that may not be worth it.
Personally, I would strongly reconsider using assertions as it adds an external tool with its own set of problems.

Creating multiple specialized assert functions seems more appealing as users of the package can import it like a regular Go package. This option can also improve code readability with descriptive function name such as `assert.LessThan`.

This is the solution I chose for my [assert](https://github.com/negrel.assert) package which is a fork of the well-known [testify](https://github.com/stretchr/testify) package.

![Gopher dropping the mic](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gfvm729xr99k79463nxm.png)