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>:
| |
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:
| |
Because zero is now “impossible,” the compiler reuses that bit pattern as the None discriminant — this is the niche optimization:
| |
Wrap it in a newtype
The real win is making invalid states unrepresentable. Stop passing raw u32 around and use a newtype:
| |
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.