#173 May 31, 2026

173. NonZeroU32 and Friends — Encode an Invariant and Shrink Option for Free

Option<u32> is 8 bytes. Option<NonZeroU32> is 4 bytes. Same information, half the size — and the compiler enforces “this can’t be zero” for you.

The problem

You have an ID, a port number, or a child-process exit code. It’s logically a u32, but 0 is meaningless or sentinel-only. So you reach for Option<u32>:

1
struct Handle { id: Option<u32> }

That’s now 8 bytes: 4 for the u32 and 4 more for the discriminant telling you which variant you’re in. Even worse, “no ID yet” and “ID is zero” are two distinct states the compiler can’t tell apart for you.

The fix: NonZero*

std::num::NonZeroU32 (and its siblings NonZeroI64, NonZeroUsize, etc.) is a u32 that’s guaranteed at the type level to never be zero. Constructors return Option so you can’t accidentally build an invalid one:

1
2
3
4
5
use std::num::NonZeroU32;

let port = NonZeroU32::new(8080).expect("non-zero");
assert_eq!(port.get(), 8080);
assert!(NonZeroU32::new(0).is_none());

Because zero is now “impossible,” the compiler reuses that bit pattern as the None discriminant — this is the niche optimization:

1
2
3
4
5
6
7
use std::mem::size_of;
use std::num::NonZeroU32;

assert_eq!(size_of::<u32>(), 4);
assert_eq!(size_of::<Option<u32>>(), 8);          // discriminant + payload
assert_eq!(size_of::<NonZeroU32>(), 4);
assert_eq!(size_of::<Option<NonZeroU32>>(), 4);   // free!

Wrap it in a newtype

The real win is making invalid states unrepresentable. Stop passing raw u32 around and use a newtype:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
use std::num::NonZeroU32;

#[derive(Copy, Clone, Debug)]
struct UserId(NonZeroU32);

impl UserId {
    fn new(raw: u32) -> Option<Self> {
        NonZeroU32::new(raw).map(UserId)
    }
    fn get(self) -> u32 {
        self.0.get()
    }
}

let alice = UserId::new(42).unwrap();
assert!(UserId::new(0).is_none());
assert_eq!(alice.get(), 42);

Now Option<UserId> is 4 bytes, Vec<Option<UserId>> is half the memory it would otherwise be, and “this u32 is a real user id, not a placeholder” is checked at construction, not at every call site.

When to reach for it

Any u32/u64/usize where zero is invalid: database row IDs, file descriptors, generation counters, capacity-like values, lengths that must be at least one. The generic NonZero<T> form also exists for cleaner code: NonZero<u32> reads the same and works in const contexts since Rust 1.79.

← Previous 172. #[track_caller] — Point the Panic at the Caller, Not Your Helper