04 — Data Types
Every type here has a memory layout with consequences for cache behavior, struct size, FFI compatibility, and where silent data loss hides. Treat types as layout facts measurable with std::mem::size_of, not syntax to memorize.
Under-the-Hood Mechanics
Integer layout and the overflow contract
| Type | Bits | Notes |
|---|---|---|
i8/u8 … i128/u128 | 8–128 | two's-complement signed, plain binary unsigned |
isize/usize | pointer-width | platform-dependent: 32 bits on wasm32, 64 on most servers |
fn main() {
let (val, ovf) = 255u8.overflowing_add(1); // (0, true)
let safe = 255u8.checked_add(1); // None
let sat = 255u8.saturating_add(1); // 255
let wrap = 255u8.wrapping_add(1); // 0
println!("{val} {ovf} {safe:?} {sat} {wrap}");
}
fn main() {
let x: u8 = 200;
let y: u8 = 100;
// let z = x + y; // debug: PANICS "attempt to add with overflow"
// release build: SILENTLY wraps to 44 — same source, different behavior by profile
}
Floats are IEEE 754, and PartialOrd (not Ord) is a direct consequence
fn main() {
let nan = f64::NAN;
assert_eq!(nan == nan, false); // NaN != NaN — IEEE 754, not a Rust choice
assert_eq!(-0.0_f64 == 0.0_f64, true); // -0.0 == 0.0
assert_eq!(nan.partial_cmp(&nan), None); // unordered — why floats impl PartialOrd, not Ord
let mut v = vec![3.0, f64::NAN, 1.0];
v.sort_by(f64::total_cmp); // total_cmp: arbitrary-but-total order, NaN included
println!("{v:?}");
}
char is 4 bytes, always — a Unicode fact, not a Rust choice
fn main() {
let c = 'z';
let emoji = '🦀';
assert_eq!(std::mem::size_of::<char>(), 4); // ALWAYS 4 bytes, even for 'z'
let ascii = "hello"; // 1 byte/char on the wire (&str, UTF-8)
let chars: Vec<char> = ascii.chars().collect(); // 4 bytes/char once collected — 4x memory
assert_eq!(ascii.len(), 5);
assert_eq!(std::mem::size_of_val(chars.as_slice()), 20);
// s[0] doesn't compile — byte offsets don't correspond to char boundaries in multi-byte UTF-8.
let s = "héllo";
// let first = s[0]; // ERROR: `String`/`str` cannot be indexed by an integer
let first = s.chars().next().unwrap();
println!("{c} {emoji} {first}");
}
Compound layout: tuples, arrays, and the niche/padding story
fn main() {
let t: (i32, f64, &str) = (1, 2.0, "three"); // repr(Rust): compiler may REORDER fields
let arr: [i32; 3] = [1, 2, 3]; // homogeneous, contiguous, zero per-element overhead
assert_eq!(std::mem::size_of::<()>(), 0); // unit — zero-sized
assert_eq!(std::mem::size_of::<[i32; 3]>(), 12); // no padding, no overhead
assert_eq!(arr.len(), 3); // compile-time constant, part of the TYPE
}
#[repr(C)] // field order GUARANTEED to match source — required for FFI
struct FfiPoint { x: i32, y: i32 }
struct RustPoint { x: i32, y: i32 } // repr(Rust): compiler free to reorder — never assume layout
The never type ! and coercion
fn main() {
let opt: Option<i32> = Some(5);
let x: i32 = match opt {
Some(v) => v,
None => panic!("missing"), // `!` coerces to i32 — this branch never actually produces a value
};
println!("{x}");
}
Cost, Performance, and Trade-Offs
Type alias: zero cost, zero safety. Newtype: zero cost, full safety.
type Kilometers = i32;
type Miles = i32;
fn distance_km(d: Kilometers) -> Kilometers { d }
fn main() {
let miles: Miles = 10;
println!("{}", distance_km(miles)); // compiles! Kilometers and Miles are BOTH just i32 — no protection
}
struct Kilometers(i32);
struct Miles(i32);
fn distance_km(d: Kilometers) -> Kilometers { d }
fn main() {
let miles = Miles(10);
// println!("{:?}", distance_km(miles).0); // COMPILE ERROR: expected Kilometers, found Miles
// Same layout as i32 at runtime (niche-filling for single-field repr(Rust) wrappers) — free.
}
char vs u8 at scale; as casts are free but not safe; ZSTs are genuinely free:
fn main() {
let big = 1_000_000_000u32;
let truncated = big as u8; // silent truncation to the low byte — no warning, no panic, ever
println!("{truncated}"); // prints 0
let checked = u8::try_from(big); // Err — explicit, handleable failure instead
println!("{checked:?}");
struct Marker; // zero-sized type
assert_eq!(std::mem::size_of::<Marker>(), 0);
let v: Vec<()> = vec![(); 1_000_000]; // zero allocation for element storage
}
Production Failure Modes & Anti-Patterns
Anti-pattern: silent truncation via as on a value crossing a trust boundary.
fn set_port(raw: u32) -> u16 {
raw as u16 // 70000 as u16 -> 4464, silently wrong, no error
}
fn set_port(raw: u32) -> Result<u16, std::num::TryFromIntError> {
u16::try_from(raw) // Err if raw > u16::MAX — caller must handle it
}
Anti-pattern: assuming usize is always 64-bit.
fn pack_id(high: u32, low: u32) -> usize {
((high as usize) << 32) | (low as usize) // works on x86_64, breaks/truncates on wasm32
}
fn pack_id(high: u32, low: u32) -> u64 {
((high as u64) << 32) | (low as u64) // explicit width, portable across every target
}
Architectural Application
Newtypes at every domain boundary where two same-typed primitives could be confused:
struct UserId(u64);
struct OrderId(u64);
fn charge(user: UserId, order: OrderId) { /* ... */ }
fn main() {
let u = UserId(1);
let o = OrderId(2);
charge(u, o);
// charge(o, u); // COMPILE ERROR — caught for zero runtime cost, impossible with two plain u64s
}
Choose overflow semantics per domain, deliberately:
fn ledger_add(balance_cents: i64, delta: i64) -> Result<i64, &'static str> {
balance_cents.checked_add(delta).ok_or("overflow in financial calculation")
// Money math: checked_* + propagate an error. Silent wraparound here is a bad incident.
}
fn ring_buffer_index(i: usize, capacity: usize) -> usize {
i.wrapping_rem(capacity) // ring buffer: wraparound IS the desired behavior
}
#[repr(C)] as the boundary marker for anything outside pure in-process Rust:
#[repr(C)]
struct WireMessage { kind: u8, length: u32, payload_ptr: *const u8 }
// Any FFI boundary, wire protocol, or memory-mapped format MUST use this — never default repr(Rust).
💡 Tips & Tricks
fn print_type<T>(_: &T) {
println!("{}", std::any::type_name::<T>()); // confirm exactly which concrete type got inferred
}
fn main() {
let f: f64 = 0.1 + 0.2;
println!("{:x}", f.to_bits()); // exact IEEE 754 bit pattern — debug "why doesn't this float equal that"
}
- Performance: prefer
.copied()over.cloned()on&Titerators whereT: Copy— fails to compile ifTstops beingCopy, catching an accidental deep-clone at the type level. - Idiom: reach for newtype the instant two same-typed parameters could be swapped by a caller.
- Clippy:
clippy::cast_possible_truncation,cast_sign_loss,cast_precision_lossflag riskyascasts. - Portability: never serialize
usize/isizedirectly — convert tou64/i64at the boundary.
⚠️ Edge Cases & Gotchas
fn main() {
let cp = 'A' as u32; // 65 — direct, infallible
let back = char::from_u32(65); // Some('A') — returns Option, since not every u32 is valid
let invalid = char::from_u32(0xD800); // None — surrogate range excluded
println!("{cp} {back:?} {invalid:?}");
// let x: u8 = 256; // COMPILE-TIME error: literal doesn't fit — different from runtime overflow
let arr = [1, 2, 3];
let dup = vec![arr; 4]; // OK: [i32; 3] is Copy
// let s = [String::new(), String::new()];
// let dup2 = vec![s; 4]; // COMPILE ERROR: [String; 2] is not Copy
let set: std::collections::HashMap<&str, ()> = [("a", ()), ("b", ())].into();
// HashMap<K, ()> — legitimate zero-overhead "set" pattern, unit occupies zero bytes
}
🧠 Spot the Bug
fn checksum(values: &[u8]) -> u8 {
let mut sum: u8 = 0;
for &v in values {
sum += v;
}
sum
}
fn main() {
let data = [200u8, 100, 50];
println!("{}", checksum(&data));
}
What does this print in release, and why might debug behave entirely differently?
Answer
// debug: panics "attempt to add with overflow" — 200 + 100 = 300 overflows u8 on the FIRST addition
// release: wraps silently, prints 94 — (200 + 100 + 50) % 256, no check at all
checksum can pass every cargo test/cargo run (debug — would panic loudly) while shipping a silently wrong result the first time it runs under cargo run --release, if no test happened to also run against the release profile.
fn checksum(values: &[u8]) -> u8 {
values.iter().fold(0u8, |acc, &v| acc.wrapping_add(v)) // explicit, profile-independent
}
The lesson: any arithmetic where overflow is a real possibility needs explicit wrapping_*/checked_*/saturating_*, not reliance on whichever build profile happens to be running.
Summary
Scalar and compound types have precise, inspectable memory layouts (size_of is your ground truth); overflow and casting are profile-dependent or silently lossy by design, making explicit checked_*/TryFrom conversions a correctness requirement at any trust or platform boundary; newtypes turn primitive-confusion bugs into compile errors for free.
Next: Functions — how these types flow through function boundaries, get monomorphized under generics, and interact with divergence (!) at call sites.