SuperAPI AI Directory

rust-core-type-system

Coding & development

View repository

Updated 10-15 22:18

About this skill

Rust Core: Type System

Conceptual map of how Rust types are identified, laid out in memory, optimized, and composed. Read this before reaching for #[repr(C)], PhantomData, NonZero*, or the never type !.

For const-generic mechanics see [[rust-syntax-generics]]. For the edition-2024 never-type fallback semantics see [[rust-syntax-edition-2024]]. For repr(C) in FFI bindings see [[rust-impl-ffi-bindgen]].

Quick reference

Concept One-liner Use when
Nominal typing Identity is by name, not shape ALWAYS expect newtype struct Meters(f64) to be distinct from struct Seconds(f64)
ZST (), PhantomData<T>, struct Unit; occupy 0 bytes Marker types, type-state, encoding info at zero runtime cost
#[repr(Rust)] (default) Compiler chooses layout, may reorder fields ALL pure-Rust code (best memory packing, no FFI)
#[repr(C)] C layout, declaration order, predictable padding FFI boundary, struct shared with C/C++
#[repr(transparent)] Single non-ZST field, same ABI as that field Newtype wrappers that must be ABI-identical to inner
#[repr(packed(N))] Disable padding (DANGEROUS) Network/binary protocols with explicit alignment rules; never take &field
#[repr(align(N))] Force higher alignment Cache-line padding, hardware alignment requirements
#[repr(u8)] on enum Stable discriminant size FFI enums, on-wire encoding
Niche optimization Invalid bit patterns encode None Option<NonZeroU32> = 4 bytes (not 8), Option<&T> = pointer size
Never type ! Empty type, coerces to any other type Return type of panic!, loop {}, return, continue
Type-state pattern PhantomData<State> encodes state at compile time Builders requiring ordered configuration; resources with lifecycle
Const generics Type parameters that are values, e.g. <const N: usize> Fixed-size arrays, dimension-tagged matrices

Decision trees

Which repr do I need?

Is this struct/enum crossing an FFI boundary?
├── No  : Use default repr (no annotation). Compiler optimizes layout.
└── Yes
    ├── Is it a newtype wrapping ONE non-ZST field with same ABI?
    │   └── Use #[repr(transparent)]
    ├── Is it a multi-field struct shared with C?
    │   └── Use #[repr(C)]
    ├── Is it a field-less enum with stable discriminant?
    │   └── Use #[repr(u8)] / #[repr(i32)] / etc.
    └── Are you implementing a wire/binary protocol with explicit byte layout?
        └── Use #[repr(C, packed(N))]. NEVER take references to fields.

When do I reach for PhantomData<T>?

Does the struct's type signature have a generic parameter T it does NOT store?
├── No  : Don't add PhantomData.
└── Yes : You need to express the relationship to T anyway. Use PhantomData<T>.
    ├── Want covariance + drop check? -> PhantomData<T>
    ├── Want invariance? -> PhantomData<fn(T) -> T>
    ├── Want contravariance? -> PhantomData<fn(T) -> ()>
    └── Want !Send / !Sync opt-out? -> PhantomData<*mut ()>

When does niche optimization apply?

Is the inner type guaranteed to have an invalid bit pattern?
├── NonZero* (NonZeroU8..NonZeroU128, NonZeroUsize, NonZeroI*) : YES, zero is the niche
├── &T, &mut T, Box<T>, fn pointers                            : YES, null is the niche
├── NonNull<T>                                                  : YES, null is the niche
├── char                                                        : YES, surrogate range + values >0x10FFFF are niches
├── bool                                                        : YES, only 0 and 1 are valid
└── Plain u32, i32, f32                                         : NO, every bit pattern is valid

If yes : Option<T> and Result<T, ZST> have the same size as T. If no : Option<T> is at least T + 1 byte (likely more due to alignment padding).

Patterns

Pattern : Nominal typing for unit safety

// Two structs, identical shape, DIFFERENT TYPES. Compiler enforces conversion.
struct Meters(f64);
struct Feet(f64);

fn distance_in_meters(d: Meters) -> Meters { d }
// distance_in_meters(Feet(10.0));  // ERROR : expected Meters, found Feet

ALWAYS prefer newtype wrappers over raw primitives at API boundaries. NEVER pass f64 between modules when the value has units, IDs, or domain constraints.

Pattern : ZST as marker

struct LoggingEnabled;
struct LoggingDisabled;

struct Logger<S> {
    target: String,
    _state: std::marker::PhantomData<S>,
}

impl Logger<LoggingDisabled> {
    fn enable(self) -> Logger<LoggingEnabled> {
        Logger { target: self.target, _state: std::marker::PhantomData }
    }
}

impl Logger<LoggingEnabled> {
    fn log(&self, msg: &str) { println!("[{}] {}", self.target, msg); }
}

size_of::<PhantomData<T>>() == 0. The state is enforced entirely at compile time, with zero runtime cost. Per std::marker::PhantomData : align_of::<PhantomData<T>>() == 1.

Pattern : repr(transparent) newtype for FFI

#[repr(transparent)]
pub struct FileDescriptor(i32);

// Same ABI as i32. Safe to pass to C functions expecting int.
// Same niche behaviour as i32 (none, so Option<FileDescriptor> is larger).

ALWAYS use #[repr(transparent)] for FFI newtypes that wrap a single primitive. NEVER use #[repr(C)] for single-field newtypes when you want to preserve the inner type's ABI (repr(C) gives the struct C-struct ABI, which differs from primitive ABI on some platforms).

Pattern : Niche-optimized handles

use std::num::NonZeroU32;

pub struct EntityId(NonZeroU32);
// Option<EntityId> is 4 bytes, NOT 8 bytes.
// `None` is encoded as the bit pattern 0, which is invalid for NonZeroU32.

impl EntityId {
    pub fn new(raw: u32) -> Option<Self> {
        NonZeroU32::new(raw).map(EntityId)
    }
}

Per std::num::NonZeroU32 : size_of::<Option<NonZeroU32>>() == size_of::<u32>(). ALWAYS prefer NonZeroU32 (or NonZeroUsize) for IDs and handles where 0 is invalid. The compiler reclaims the zero bit pattern for the None discriminant.

Pattern : Never type for diverging functions

fn fatal(msg: &str) -> ! {
    eprintln!("FATAL: {msg}");
    std::process::exit(1);
}

let x: i32 = match parse_input() {
    Ok(n) => n,
    Err(_) => fatal("bad input"),  // ! coerces to i32 (or anything)
};

The never type ! is the type of expressions that never produce a value. It coerces to any other type, which is what makes panic!, loop {}, return, and continue usable in arbitrary positions.

Pattern : Type-state builder

use std::marker::PhantomData;

pub struct NeedsName;
pub struct NeedsAge;
pub struct Ready;

pub struct UserBuilder<State> {
    name: Option<String>,
    age: Option<u32>,
    _state: PhantomData<State>,
}

impl UserBuilder<NeedsName> {
    pub fn new() -> Self {
        Self { name: None, age: None, _state: PhantomData }
    }
    pub fn name(self, name: String) -> UserBuilder<NeedsAge> {
        UserBuilder { name: Some(name), age: self.age, _state: PhantomData }
    }
}

impl UserBuilder<NeedsAge> {
    pub fn age(self, age: u32) -> UserBuilder<Ready> {
        UserBuilder { name: self.name, age: Some(age), _state: PhantomData }
    }
}

impl UserBuilder<Ready> {
    pub fn build(self) -> User { User { name: self.name.unwrap(), age: self.age.unwrap() } }
}

pub struct User { name: String, age: u32 }

// Compile-time enforcement : .build() unreachable until .name() and .age() called in order.

Primitive types reference

Type Size Niche Notes
i8..i128 1..16 bytes No Two's complement
isize pointer-sized No Platform dependent
u8..u128 1..16 bytes No Unsigned
usize pointer-sized No Index type for slices/arrays
f32, f64 4, 8 bytes No IEEE 754
bool 1 byte Yes (only 0/1 valid) Option<bool> = 1 byte
char 4 bytes Yes Unicode scalar value, not UTF-8 code unit
() (unit) 0 bytes n/a ZST, sole inhabitant ()
! (never) 0 bytes n/a No inhabitants, coerces to any type

char is ALWAYS 4 bytes. NEVER conflate char with a UTF-8 byte (use u8 for raw bytes or iterate str::chars() for scalars).

Edition 2024 : never-type fallback change

CRITICAL behavioural change in edition 2024 (Rust 1.85+). Before edition 2024, type inference allowed ! to fall back to (). In edition 2024, it falls back to ! itself.

// Edition 2021 behaviour : T inferred as ()
// Edition 2024 behaviour : T inferred as ! -> compile error if ! does not satisfy bounds
fn outer<T>(x: T) -> Result<T, ()> {
    fn f<T: Default>() -> Result<T, ()> { Ok(T::default()) }
    f()?;       // Edition 2024 : T = !, but ! : Default not satisfied -> ERROR
    Ok(x)
}

// Fix : explicit turbofish
fn outer_fixed<T>(x: T) -> Result<T, ()> {
    fn f<T: Default>() -> Result<T, ()> { Ok(T::default()) }
    f::<()>()?; // explicit T = ()
    Ok(x)
}

ALWAYS migrate via explicit type annotations when upgrading to edition 2024. The never_type_fallback_flowing_into_unsafe lint is deny by default, catching the dangerous cases at compile time. See [[rust-syntax-edition-2024]] for the full migration checklist.

Const generics overview

<const N: usize> parameterizes a type by a value, not another type. Stable for primitive integer types, bool, and char.

pub struct Matrix<const ROWS: usize, const COLS: usize> {
    data: [[f64; COLS]; ROWS],
}

impl<const R: usize, const C: usize> Matrix<R, C> {
    pub fn zero() -> Self { Self { data: [[0.0; C]; R] } }
}

let m: Matrix<3, 4> = Matrix::zero();

ALWAYS use const generics for fixed-size APIs (matrices, ring buffers, fixed-key crypto). NEVER attempt complex const expressions like <const N: usize, const M: usize> with N + M in bounds without the generic_const_exprs feature (still unstable). For const-generic depth see [[rust-syntax-generics]].

Reference files

External sources