Enums and Pattern Matching

Enums and Memory Representation

In memory, a Rust enum is stored as a tagged union (discriminated union): a numeric discriminant (tag) that identifies which variant is currently active, followed by enough inline space (plus alignment padding) to hold the largest variant's payload.

Tagged Union Layout Rule

The size of an enum on the stack (std::mem::size_of::<Enum>()) is determined by:

  1. The discriminant tag: Typically 1 byte (u8) when there are up to 256 variants, widened to fit the alignment of the largest payload field.
  2. The largest variant's payload: All variants share the same payload memory bytes; only the active variant's bytes are interpreted.
  3. Alignment padding: Total size is rounded up to a multiple of the strictest field alignment (align_of::<Enum>()).
Memory Layout of `enum Message` (Total size = 24 bytes, Alignment = 8 bytes):

  enum Message {
      Quit,                       // 0 bytes payload
      Move { x: i32, y: i32 },    // 8 bytes payload (4B + 4B)
      Write(String),              // 24 bytes payload (ptr + len + cap, Niche-Optimized!)
  }

  Without Niche Optimization (e.g., `enum Value { Empty, Pair(u64, u64) }` = 24 bytes):
  +------------------+--------------------------+--------------------------+
  | Tag + Padding    | Payload Word 1 (8 bytes) | Payload Word 2 (8 bytes) |
  | (8 bytes)        |                          |                          |
  +------------------+--------------------------+--------------------------+
use std::mem::{align_of, size_of};

enum Direction {
    North,
    South,
    East,
    West,
}

enum Metric {
    Empty,
    Counter(u64),
    Coordinates(u64, u64),
}

fn main() {
    println!(
        "Direction: size = {} byte,  align = {} byte",
        size_of::<Direction>(),
        align_of::<Direction>()
    );
    println!(
        "Metric:    size = {} bytes, align = {} bytes",
        size_of::<Metric>(),
        align_of::<Metric>()
    );
}
Direction: size = 1 byte,  align = 1 byte
Metric:    size = 24 bytes, align = 8 bytes

Niche Optimization (Zero-Overhead Option<T>)

A niche is an invalid bit pattern in a type that can never occur in a valid instance—for example, the address 0x0 (null) for &T, &mut T, Box<T>, NonNull<T>, or NonZeroU64, or byte values 2..=255 for bool.

When an enum has one data-carrying variant with a niche and unit variants (such as Option<&T> or Option<String>), the compiler stores the None discriminant inside the niche bit pattern (0x0), eliminating the separate discriminant tag altogether:

Type T size_of::<T>() size_of::<Option<T>>() Niche Used for None
&u64 / Box<u64> 8 bytes 8 bytes Null pointer (0x0)
&[u8] / &str 16 bytes 16 bytes Null data pointer (0x0)
String / Vec<u8> 24 bytes 24 bytes Null heap pointer (Unique<u8>)
std::num::NonZeroU64 8 bytes 8 bytes Zero value (0)
bool 1 byte 1 byte Value 2 (0 = Some(false), 1 = Some(true), 2 = None)
u64 (no niche; all 2^64 patterns valid) 8 bytes 16 bytes None available; requires separate tag + padding
use std::mem::size_of;
use std::num::NonZeroU64;

fn main() {
    println!("u64:                  {} bytes", size_of::<u64>());
    println!("Option<u64>:          {} bytes (needs extra tag)", size_of::<Option<u64>>());
    println!("Option<NonZeroU64>:   {} bytes (niche-optimized!)", size_of::<Option<NonZeroU64>>());
    println!("Option<&u64>:         {} bytes (niche-optimized!)", size_of::<Option<&u64>>());
    println!("Option<String>:       {} bytes (niche-optimized!)", size_of::<Option<String>>());
}
u64:                  8 bytes
Option<u64>:          16 bytes (needs extra tag)
Option<NonZeroU64>:   8 bytes (niche-optimized!)
Option<&u64>:         8 bytes (niche-optimized!)
Option<String>:       24 bytes (niche-optimized!)

Boxing Large Variants to Avoid Enum Size Bloat

Because an enum is always as large as its largest variant, placing a single 512-byte struct directly inside one rarely used variant bloats every instance of the enum to over 512 bytes. Wrapping the oversized variant's payload in Box<LargeData> shrinks that variant's inline payload to an 8-byte pointer:

use std::mem::size_of;

enum UnboxedEvent {
    Tick,
    Snapshot([u8; 512]),
}

enum BoxedEvent {
    Tick,
    Snapshot(Box<[u8; 512]>),
}

fn main() {
    println!("UnboxedEvent size: {} bytes", size_of::<UnboxedEvent>());
    println!("BoxedEvent size:   {} bytes", size_of::<BoxedEvent>());
}
UnboxedEvent size: 513 bytes
BoxedEvent size:   8 bytes

Controlling Enum Representation (#[repr(...)])

By default (#[repr(Rust)]), the compiler is free to reorder fields and choose the smallest discriminant size or niche optimization. For FFI or binary protocols, you can specify an explicit integer discriminant representation:

  • #[repr(u8)], #[repr(u16)], #[repr(i32)], #[repr(usize)]: Forces the discriminant tag to use that exact integer type.
  • #[repr(C)]: Uses the platform C ABI layout rules for fieldless enums or tagged unions.