Day at the office

Coding is mostly about communication. We communicate with the machine when we give it instructions. But we also communicate with our colleagues (and our future selves) through design and comments.

I was refactoring some code at work. The interface is a Categorizer that categorizes information according to a specified version.

I stumbled on a struct like the following.

type Request struct {
    CategoryVersion string // one of {"1.1", "2.1", "2.2", "3.1"}
}

type Response struct {
    CategoryVersion string // one of {"1.1", "2.1", "2.2", "3.1"}
    CategoryCode    string // one of {"123", "456"}
}

The code was working well, no doubt. The problem is that while refactoring, it is extremely easy to mix up Version and Code. Both are strings, so the compiler happily lets you pass one where the other is expected. This mix-up is caused by the heavy use of string to represent different concepts.

That reminded me of an interesting pattern I saw in Rust that the current situation could leverage.

Newtype

The idea of newtype is to create a custom type to wrap those stronger identifiers that are backed by a simple type.

For instance, in our current scenario we can refactor in the following manner.

type CategoryVersion string

func NewCategoryVersion(version string) CategoryVersion {
    return CategoryVersion(version)
}

type CategoryCode string

func NewCategoryCode(code string) CategoryCode {
    return CategoryCode(code)
}

type Request struct {
    CategoryVersion CategoryVersion
}

type Response struct {
    CategoryVersion CategoryVersion
    CategoryCode    CategoryCode
}

func Categorize(v CategoryVersion, c CategoryCode) Response {
    return Response{CategoryVersion: v, CategoryCode: c}
}

// Categorize(c, v) does NOT compile:
//   cannot use c (variable of string type CategoryCode) as CategoryVersion value in argument to Categorize
//   cannot use v (variable of string type CategoryVersion) as CategoryCode value in argument to Categorize

Run it on the Go Playground

This gives us stronger types to play with those identifiers. Now func Categorize(v CategoryVersion, c CategoryCode) will not compile if you swap the two arguments, and the mistake is caught at build time instead of in production.

The constructors do not do much yet: right now they are just conversions. They become useful once we hang validation off them, which is where we are headed.

Closed sets: reach for constants first

Our versions are a small, known set: {"1.1", "2.1", "2.2", "3.1"}. When the set of valid values is closed and lives in your code, typed constants are the simplest strong option.

type CategoryVersion string

const (
    V11 CategoryVersion = "1.1"
    V21 CategoryVersion = "2.1"
    V22 CategoryVersion = "2.2"
    V31 CategoryVersion = "3.1"
)

Run it on the Go Playground

Now the rest of the code refers to V21 instead of a bare "2.1", so a typo becomes a compile error and your editor can autocomplete the valid options.

Values from outside: validate at construction

Constants only help for values you write yourself. The moment a version arrives from user input, a database, or an API, you are back to an arbitrary string, and you want to check it once, at the boundary.

import "fmt"

type CategoryVersion struct {
    version string
}

var validVersions = map[string]bool{
    "1.1": true,
    "2.1": true,
    "2.2": true,
    "3.1": true,
}

func NewCategoryVersion(version string) (CategoryVersion, error) {
    if !validVersions[version] {
        return CategoryVersion{}, fmt.Errorf("unknown category version %q", version)
    }

    return CategoryVersion{version: version}, nil
}

Run it on the Go Playground

Because version is unexported, code outside this package cannot set it directly. It has to go through NewCategoryVersion, and that is exactly where our validation lives. So once you hold a CategoryVersion, you know it was checked.

Rust

In Rust, you can attach methods to a type directly, which makes it a little more convenient.

pub struct CategoryVersion(String);

#[derive(Debug)]
pub enum CategoryError {
    Empty,
}

impl CategoryVersion {
    pub fn new(raw: impl Into<String>) -> Result<Self, CategoryError> {
        let raw = raw.into();
        if raw.is_empty() {
            return Err(CategoryError::Empty);
        }
        Ok(CategoryVersion(raw))
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

The field inside the tuple struct is private by default, so, just like the Go example, the only way to build a CategoryVersion from outside the module is through new, which validates.

Usage looks like this.

let version = CategoryVersion::new("2.1")?;

// This does not compile: a CategoryCode is not a CategoryVersion.
// categorize(code, version);

categorize(version, code);

And you get nice conversion code by implementing a couple of standard traits.

use std::fmt;

// Print it like a plain string.
impl fmt::Display for CategoryVersion {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}

// Fallible conversion straight from &str.
impl TryFrom<&str> for CategoryVersion {
    type Error = CategoryError;

    fn try_from(raw: &str) -> Result<Self, Self::Error> {
        CategoryVersion::new(raw)
    }
}

Run it on the Rust Playground

Go trap

Be careful when implementing newtype in Go. You can easily bypass the validation.

Within the same package, nothing stops you from building the value directly and skipping the constructor entirely.

// Skips NewCategoryVersion, so the validation never runs.
bad := CategoryVersion{}

// For the `type CategoryVersion string` variant, a plain
// conversion bypasses validation too.
alsoBad := CategoryVersion("literally anything")

The unexported field only protects you across package boundaries. So if you rely on validation, put the newtype in a package of its own. That way the constructor is the single door in, and the rest of your codebase physically cannot construct an invalid value. You do not need one package per type, though: a small internal package that groups related domain types is usually enough.

Cost

Does this abstraction cost anything at runtime? In short: no. And rather than take my word for it, we can measure it.

The tool output below is from Go 1.26 and rustc 1.92 on amd64. Exact registers and byte counts vary across compiler versions and architectures, but the conclusion (identical layout, wrapper folded away) does not.

Go: same size

A defined type shares the memory layout of its underlying type. In the words of the Go spec, “each type T has an underlying type,” and a value can be converted between a type and its underlying type precisely because the two share the same representation. We can confirm with unsafe.Sizeof.

var s string                    // size=16 align=8
var a CategoryVersion           // type CategoryVersion string
var st struct{ version string } // the struct variant

// unsafe.Sizeof reports:
// string:                size=16 align=8
// type X string:         size=16 align=8
// struct{ version str }: size=16 align=8

All three are 16 bytes: the two words of a string header (a data pointer and a length). Wrapping adds no header and no allocation.

Go: the accessor disappears

Small accessor methods get inlined. Compile with -gcflags=-m and the compiler tells you so:

cost/asm/lib.go:21:57: inlining call to CategoryVersion.Value

Better still, compare the generated code directly. Take a function that goes through the newtype and its accessor, and one that touches the raw string, and force both to stay real functions with //go:noinline:

//go:noinline
func Wrapped(c CategoryVersion) int { return len(c.Value()) }

//go:noinline
func Raw(s string) int { return len(s) }

go tool objdump gives byte-for-byte identical bodies:

TEXT main.Wrapped(SB)          TEXT main.Raw(SB)
  MOVQ AX, 0x8(SP)               MOVQ AX, 0x8(SP)
  MOVQ BX, AX                    MOVQ BX, AX
  RET                           RET

The wrapper leaves no trace in the machine code.

Rust: same story, proven by the linker

A Rust newtype is a zero-cost abstraction. struct CategoryVersion(String) has the same size and alignment as the String it wraps, and size_of confirms it:

String:                    size=24 align=8
CategoryVersion(String):   size=24 align=8
repr(transparent) wrapper: size=24 align=8

If you want that identical layout guaranteed (rather than merely observed), add #[repr(transparent)], which promises the wrapper is laid out exactly like its single field.

The accessor gets the same treatment. Compile a wrapped length accessor and a raw one to assembly with rustc -O --emit asm, and LLVM does not just produce equivalent code, it recognises the two functions are identical and makes one an alias of the other:

raw_len:
    movq    16(%rdi), %rax
    retq
wrapped_len = raw_len            # same symbol, folded by the optimiser

The wrapper exists only to satisfy the type checker and is gone by the time the machine runs your code.

So you get the safety for free.

Conclusion

Newtypes are pretty cool. They cost you almost nothing at runtime, they turn a whole class of mix-ups into compile errors, and they give validation a single place to live. But the real win is communication: a parameter typed CategoryVersion tells the next reader what that string means, where a bare string tells them nothing. Your future self will thank you.

References