Const Generics in Production: Type-Level Arrays and Beyond

There’s a moment in every Rust developer’s career when you realize the standard library’s [T; N] arrays are secretly magic. You can’t write a function that’s generic over the length N. You end up with a Vec when you wanted a stack-allocated buffer, or you write the same function five times for five different array sizes, or you reach for the arrayvec crate as a band-aid. Const generics fix all of that — and they’ve been on stable Rust since 1.51. Yet most production codebases still don’t use them. This article fixes that.

We’ll cover the full picture: basic syntax, structs and traits with const params, the wall you’ll hit on stable when trying to do arithmetic on const generics, and the real patterns I’ve used in production — fixed-size ring buffers, type-safe packet framing, and SIMD-width-polymorphic math.

What Const Generics Actually Are

Normal generics are parametric over types. Const generics are parametric over values of certain primitive types — currently usize, u8u128, i8i128, bool, and char. The use case that drove the feature was arrays, but the design is broader.

// Before const generics: this was impossible to write generically
fn sum_array<const N: usize>(arr: [f64; N]) -> f64 {
    arr.iter().sum()
}

fn main() {
    println!("{}", sum_array([1.0, 2.0, 3.0]));     // N inferred as 3
    println!("{}", sum_array([1.0, 2.0, 3.0, 4.0])); // N inferred as 4
}

The compiler monomorphizes this just like type generics — you get one specialized copy per distinct N actually used, with zero runtime overhead. The array size baked in at the call site.

This sounds modest. It isn’t.

Structs with Const Params

The real power comes from putting const params on structs. You encode invariants that previously required runtime checks.

/// A fixed-capacity stack. Capacity is a compile-time constant.
/// No heap allocation, no reallocation, no capacity checks at runtime.
pub struct FixedStack<T, const CAP: usize> {
    data: [Option<T>; CAP],
    len: usize,
}

impl<T: Copy, const CAP: usize> FixedStack<T, CAP> {
    pub fn new() -> Self {
        Self {
            data: [None; CAP],
            len: 0,
        }
    }

    pub fn push(&mut self, val: T) -> Result<(), T> {
        if self.len == CAP {
            return Err(val); // full — no panic, caller decides
        }
        self.data[self.len] = Some(val);
        self.len += 1;
        Ok(())
    }

    pub fn pop(&mut self) -> Option<T> {
        if self.len == 0 {
            return None;
        }
        self.len -= 1;
        self.data[self.len].take()
    }

    pub const fn capacity(&self) -> usize {
        CAP // no field needed — CAP is a compile-time constant
    }
}

FixedStack<u32, 16> and FixedStack<u32, 1024> are distinct types. You cannot pass one where the other is expected. The size lives in the type signature, not in a runtime field. Stack allocation, no Box, no Vec.

Traits Over Const Params

You can implement traits generically over const params, which is where this feature starts feeling like type-level programming:

use std::fmt;

impl<T: fmt::Debug + Copy, const CAP: usize> fmt::Debug for FixedStack<T, CAP> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_list()
            .entries(self.data[..self.len].iter().filter_map(|x| x.as_ref()))
            .finish()
    }
}

// Blanket impl: any FixedStack of any size is Default, as long as T is Copy + Default
impl<T: Copy + Default, const CAP: usize> Default for FixedStack<T, CAP> {
    fn default() -> Self {
        Self {
            data: [None; CAP],
            len: 0,
        }
    }
}

You can also use const params in where clauses, though this is where the stable/nightly split starts to matter.

The Big Gotcha: Const Generic Expressions on Stable

Here’s the wall you will hit. Suppose you want a Matrix<const ROWS: usize, const COLS: usize> and you want the internal storage to be [f32; ROWS * COLS]. Intuitive. Doesn’t compile on stable.

// This does NOT compile on stable Rust (1.85 as of writing)
struct Matrix<const ROWS: usize, const COLS: usize> {
    data: [f32; ROWS * COLS], // error: generic parameters may not be used in const operations
}

The feature that allows this — const generic expressions — is behind the #![feature(generic_const_exprs)] gate and has been unstable for years. It’s one of the most-requested features to stabilize, but the soundness story is complicated.

Your options on stable Rust:

Option 1: Use a helper trait with an associated const.

pub trait ArraySize {
    const SIZE: usize;
}

pub struct Dim<const ROWS: usize, const COLS: usize>;

impl<const ROWS: usize, const COLS: usize> ArraySize for Dim<ROWS, COLS> {
    const SIZE: usize = ROWS * COLS; // This computation lives in an impl, not in the generic param position
}

This still doesn’t get you a [f32; <Dim<ROWS, COLS> as ArraySize>::SIZE] field on stable. The compiler doesn’t evaluate associated consts in that context without generic_const_exprs.

Option 2: Accept a third const parameter for the total size.

/// Caller provides ROWS, COLS, and ROWS*COLS. It's redundant but stable.
/// Use a constructor to enforce the invariant.
pub struct Matrix<const ROWS: usize, const COLS: usize, const TOTAL: usize> {
    data: [f32; TOTAL],
}

impl<const ROWS: usize, const COLS: usize, const TOTAL: usize> Matrix<ROWS, COLS, TOTAL> {
    pub fn new(data: [f32; TOTAL]) -> Self {
        // Runtime assertion catches miscalculated TOTAL
        assert_eq!(ROWS * COLS, TOTAL, "TOTAL must equal ROWS * COLS");
        Self { data }
    }
}

type Mat4 = Matrix<4, 4, 16>;

Ugly, but it works. The typenum crate (pre-const-generics era) solved this more elegantly at the cost of enormous complexity; most new code should just wait for generic_const_exprs to stabilize or eat the extra parameter.

Option 3: Use nightly and accept the instability.

For internal tooling, research code, or projects where you control the compiler version, #![feature(generic_const_exprs)] works fine in practice today. Just don’t publish a library with it — that forces the constraint on all your users.

Real Pattern: Type-Safe Network Frames

Here’s a production pattern that doesn’t need generic_const_exprs. Fixed-size network protocol framing where the frame size is a compile-time constant:

/// A protocol frame with a fixed-size payload.
/// The type system prevents you from mixing frame sizes.
#[derive(Debug, Clone)]
pub struct Frame<const N: usize> {
    header: [u8; 4],
    payload: [u8; N],
    checksum: u32,
}

impl<const N: usize> Frame<N> {
    pub const WIRE_SIZE: usize = 4 + N + 4;

    pub fn new(payload: [u8; N]) -> Self {
        let checksum = crc32_of(&payload);
        Self {
            header: *b"FRM\x01",
            payload,
            checksum,
        }
    }

    pub fn to_bytes(&self) -> [u8; { 4 + N + 4 }] {
        // Note: this specific syntax DOES work on stable for simple additions
        // involving only const params and literal integers
        let mut buf = [0u8; { 4 + N + 4 }];
        buf[..4].copy_from_slice(&self.header);
        buf[4..4 + N].copy_from_slice(&self.payload);
        buf[4 + N..].copy_from_slice(&self.checksum.to_le_bytes());
        buf
    }
}

// These are distinct types. The compiler will reject mixing them.
type SmallFrame = Frame<64>;
type LargeFrame = Frame<1400>;

fn crc32_of(data: &[u8]) -> u32 {
    // placeholder
    data.iter().fold(0u32, |acc, &b| acc.wrapping_add(b as u32))
}

The { 4 + N + 4 } expression in the return type of to_bytes works on stable because it’s a simple arithmetic expression involving a const param and literals — the compiler can evaluate it. Complex expressions like N * M for two independent const params don’t work yet.

Real Pattern: SIMD-Width-Polymorphic Math

If you write performance-sensitive numerical code, you often want functions that operate on a "chunk" of values whose size matches the SIMD register width of the target. Const generics let you write this once:

/// Process a slice in fixed-size chunks.
/// The chunk size is a compile-time constant — no runtime dispatch,
/// the inner loop body gets fully unrolled by the optimizer for small N.
pub fn process_chunks<const CHUNK: usize>(data: &[f32]) -> Vec<f32> {
    let mut out = Vec::with_capacity(data.len());

    let chunks = data.chunks_exact(CHUNK);
    let remainder = chunks.remainder();

    for chunk in chunks {
        // The compiler sees a slice of exactly CHUNK elements.
        // With target-feature=+avx2 and CHUNK=8, this often auto-vectorizes.
        let mut acc = [0.0f32; CHUNK];
        for (a, &b) in acc.iter_mut().zip(chunk) {
            *a = b * b + b; // whatever your kernel is
        }
        out.extend_from_slice(&acc);
    }

    // Handle tail without branching inside the hot loop
    for &x in remainder {
        out.push(x * x + x);
    }

    out
}

// At call sites, pick the width that matches your target:
// process_chunks::<4>(data)  // SSE2
// process_chunks::<8>(data)  // AVX2
// process_chunks::<16>(data) // AVX-512

This pattern replaces a runtime match lane_width { 4 => ..., 8 => ..., 16 => ... } with zero-overhead dispatch through the type system.

Const Defaults (Stable Since 1.59)

You can give const generic parameters default values, which is especially useful for container types where 90% of users want the same size:

/// Ring buffer with a default capacity of 256 entries.
/// Most users write: RingBuffer::<Event>
/// Power users write: RingBuffer::<Event, 1024>
pub struct RingBuffer<T, const CAP: usize = 256> {
    data: Vec<T>, // or [Option<T>; CAP] if you want stack allocation
    head: usize,
    tail: usize,
}

impl<T, const CAP: usize> RingBuffer<T, CAP> {
    pub fn new() -> Self {
        Self {
            data: Vec::with_capacity(CAP),
            head: 0,
            tail: 0,
        }
    }
}

// Both valid:
let buf1: RingBuffer<u8> = RingBuffer::new();         // CAP = 256
let buf2: RingBuffer<u8, 4096> = RingBuffer::new();   // CAP = 4096

Defaults compose well with the rest of the type system and show up in error messages cleanly. Use them whenever your const param has a sensible common value.

Gotcha: Const Params and Trait Object Incompatibility

Const-generic types cannot be used as trait objects without explicit handling, because each distinct N produces a different concrete type:

trait Processable {
    fn process(&self);
}

impl<const N: usize> Processable for FixedStack<u32, N> {
    fn process(&self) { /* ... */ }
}

// This will NOT work:
fn run(p: &dyn Processable) { p.process(); }
let stack16: FixedStack<u32, 16> = FixedStack::new();
let stack32: FixedStack<u32, 32> = FixedStack::new();
let items: Vec<&dyn Processable> = vec![&stack16, &stack32]; // fine — same trait!

Actually, the trait object part works. What doesn’t work is selecting behavior based on N inside a dyn Processable context — you’ve erased the const param. If you need runtime polymorphism over the size, you need a different design (pass the size as a runtime argument, or use an enum).

Gotcha: Inference Doesn’t Always Work

The compiler infers const params from usage, but it can’t always do so. When inference fails, the error messages are occasionally cryptic:

// Inference works: N comes from the array literal
let s = sum_array([1.0, 2.0, 3.0]);

// Inference fails: nothing at the call site tells the compiler what N is
let stack = FixedStack::new(); // error: type annotations needed
let stack: FixedStack<u32, 16> = FixedStack::new(); // correct

When building APIs, design constructors so the const param can be inferred from an argument when possible. from_array(arr: [T; N]) is better than new() for discoverability.

Gotcha: Monomorphization Bloat

Every distinct N you use creates a separate compiled copy of the function. For a function called with 50 different array sizes, you get 50 copies. For hot inner loops this is exactly what you want. For cold initialization code or debug utilities, it’s unnecessary bloat.

The mitigation is the same as for type generics: write the non-size-sensitive parts in terms of &[T] slices (a fat pointer with a runtime length), and only switch to const generic arrays at the boundary where you care about stack allocation or the size-dependent return type.

// Thin monomorphized entry point — only this gets duplicated
pub fn process<const N: usize>(arr: [u32; N]) -> u64 {
    process_slice(&arr) // all the logic lives here, compiled once
}

// Compiled once, takes a regular slice
fn process_slice(data: &[u32]) -> u64 {
    data.iter().map(|&x| x as u64 * x as u64).sum()
}

When to Reach for Const Generics

Use them when:

  • You want stack-allocated containers whose size is fixed at compile time.
  • You need the type system to enforce that two things have the same size (e.g., input and output buffers, matrix multiplication dimensions).
  • You’re writing numerical or SIMD code and want zero-cost "configuration" of a kernel’s lane width.
  • You’re modeling a protocol with fixed-size fields and you want the compiler to catch size mismatches.

Don’t use them when:

  • The size is genuinely dynamic — determined by user input, config files, or data on the wire. That’s what Vec and runtime parameters are for.
  • You need trait objects and the const param would be erased anyway.
  • You’re in a context where compile times already hurt; heavy monomorphization makes them worse.

Where This Is Heading

The generic_const_exprs feature is the next unlock. When it stabilizes, you’ll be able to write [T; ROWS * COLS] directly, which removes the awkward "redundant third param" workaround for 2D arrays. The tracking issue is active; the main blocker is soundness edge cases around dependent types. Nightly users can play with it today — just don’t expose it in public API surfaces.

Const generics in today’s stable Rust are already genuinely useful. The array-size-in-a-type pattern alone replaces hundreds of arrayvec usages with zero-dependency code. Start with the simple cases — fn foo<const N: usize>(arr: [T; N]) — and you’ll quickly develop an instinct for where the compile-time constant should live in your type signatures.

Leave a comment

👁 Views: 24,119 · Unique visitors: 34,689