Types, Variables, and Constants
Master Rust's type system, variable declaration, constants, and memory layout. Understand stack vs heap allocation, primitives, compound types, and how Rust prevents null pointer errors.
Every piece of data in a program exists somewhere in memory. A number occupies bytes. A string occupies bytes. A complex data structure occupies bytes. The compiler must know how many bytes each value requires and what operations are valid on those bytes. This is what types provide—a contract between you and the compiler about the nature of data.
Rust enforces this contract strictly. Every variable has a type known at compile time. The compiler rejects operations that violate type constraints. You can't accidentally treat a number as a pointer. You can't read from uninitialized memory. You can't perform arithmetic on values that aren't numbers. Type safety prevents entire categories of bugs before your code runs.
Values vs Variables
A value is data. The number 42 is a value. The string "hello" is a value. Values exist in memory at specific addresses with specific representations.
A variable is a name bound to a value. Variables let you refer to data without knowing its memory address. When you write let x = 42, you create a variable x that refers to the value 42.
Variables in Rust are immutable by default. Once you bind a value to a variable, you can't change that binding unless you explicitly declare the variable mutable with mut:
let x = 5;
x = 6; // Error: cannot assign twice to immutable variable
let mut y = 5;
y = 6; // Valid: y is mutable
This default catches bugs. Most variables shouldn't change. When you see let mut, it signals that mutation happens. Code becomes easier to reason about when most values are immutable—you know they won't change unexpectedly.
Stack vs Heap Memory
Memory divides into two primary regions: the stack and the heap. Understanding the distinction matters because it affects performance and determines how Rust manages lifetimes.
The stack grows and shrinks automatically as functions execute. When a function starts, space for its local variables allocates on the stack. When the function returns, that space deallocates immediately. Stack allocation is fast—just moving a pointer. Stack-allocated data has a known size at compile time.
Primitive types live on the stack: integers, floating-point numbers, booleans, and characters. Fixed-size arrays live on the stack. Tuples and structs containing only stack-allocated types also live on the stack.
The heap provides dynamically allocated storage. You allocate heap memory explicitly when you need variable-sized data or data that outlives the function that created it. Heap allocation is slower—the allocator must find an appropriate memory region and track it. Heap-allocated data's size can be unknown at compile time.
Strings (String), vectors (Vec<T>), and other growable collections allocate their data on the heap. The variable that owns them lives on the stack but contains a pointer to heap memory.
fn stack_example() {
let x = 42; // Stack: single i32 value
let arr = [1, 2, 3]; // Stack: fixed array of 3 i32 values
} // x and arr deallocate when function returns
fn heap_example() {
let s = String::from("hello"); // Stack: pointer, length, capacity
// Heap: actual string data
} // s deallocates, freeing heap memory
The stack-heap distinction underlies Rust's ownership system. Stack values have clear lifetimes—they live until their scope ends. Heap values require explicit management, which Rust handles through ownership.
Primitive Types
Rust provides several categories of primitive types. These types have known sizes and copy semantics—assigning one variable to another copies the value rather than moving ownership.
Integers come in signed and unsigned variants with explicit sizes. Signed integers use two's complement representation. Unsigned integers represent only non-negative values:
let a: i8 = -128; // 8-bit signed: -128 to 127
let b: i16 = -32768; // 16-bit signed: -32,768 to 32,767
let c: i32 = -2147483648; // 32-bit signed (default integer type)
let d: i64 = -9223372036854775808; // 64-bit signed
let e: i128 = 0; // 128-bit signed
let f: u8 = 255; // 8-bit unsigned: 0 to 255
let g: u16 = 65535; // 16-bit unsigned: 0 to 65,535
let h: u32 = 4294967295; // 32-bit unsigned
let i: u64 = 18446744073709551615; // 64-bit unsigned
let j: u128 = 0; // 128-bit unsigned
Architecture-dependent integers isize and usize match the pointer size of the target platform. On 64-bit systems, they're 64 bits. On 32-bit systems, they're 32 bits. Use usize for indexing into collections.
Floating-point types follow IEEE 754:
let x: f32 = 3.14; // 32-bit float (single precision)
let y: f64 = 2.71828; // 64-bit float (double precision, default)
Booleans occupy one byte and represent true or false:
let active: bool = true;
let inactive: bool = false;
Characters represent Unicode scalar values, occupying 4 bytes:
let letter: char = 'A';
let emoji: char = '😀';
let chinese: char = '中';
Unlike some languages where characters are single bytes, Rust's char can represent any valid Unicode scalar value from U+0000 to U+D7FF and U+E000 to U+10FFFF.
Compound Types
Tuples group multiple values of different types into a single compound value. Tuples have fixed length—you can't add or remove elements after creation:
let tuple: (i32, f64, char) = (42, 3.14, 'x');
// Access tuple elements by index
let number = tuple.0;
let decimal = tuple.1;
let character = tuple.2;
// Destructure tuples
let (x, y, z) = tuple;
Arrays contain multiple values of the same type with fixed length:
let numbers: [i32; 5] = [1, 2, 3, 4, 5];
let first = numbers[0];
let second = numbers[1];
// Create array with same value
let zeros = [0; 10]; // Array of 10 zeros
Arrays must have a size known at compile time. The type signature [i32; 5] specifies both element type (i32) and length (5). Accessing an invalid index causes a panic at runtime—Rust checks bounds even for arrays.
For dynamic-size collections, use Vec<T> from the standard library. Vectors grow and shrink at runtime, storing their elements on the heap.
Type Inference
Rust infers types when the compiler can determine them from context. You don't always need explicit type annotations:
let x = 42; // Inferred as i32
let y = 3.14; // Inferred as f64
let active = true; // Inferred as bool
let numbers = vec![1, 2, 3]; // Inferred as Vec<i32>
When the compiler can't infer a type, it requests an annotation:
let x = "42".parse().expect("Not a number"); // Error: type annotations needed
let x: i32 = "42".parse().expect("Not a number"); // OK
Type inference reduces verbosity without sacrificing safety. The compiler still enforces all type rules—it just doesn't require you to write them explicitly when they're obvious.
No Null Values
Most languages have a null or nil value representing the absence of data. This creates problems. When you access a variable, you can't be sure it contains valid data or null. Languages with null require defensive null checks throughout the codebase. Forgetting a null check causes null pointer exceptions at runtime.
Rust doesn't have null. Variables always contain valid values of their declared type. If you declare let x: i32, then x contains a valid 32-bit integer—never null.
When you need to represent optional values, use Option<T>:
let some_number: Option<i32> = Some(5);
let no_number: Option<i32> = None;
Option<T> is an enum with two variants: Some(T) containing a value, or None representing absence. The type system forces you to handle both cases. You can't accidentally use an Option<i32> where an i32 is expected—the compiler rejects it. To get the value out of an Option, you must explicitly check whether it's Some or None:
match some_number {
Some(value) => println!("Got: {}", value),
None => println!("Got nothing"),
}
This eliminates null pointer exceptions. If your code compiles, you've handled the absence-of-value case. The compiler enforces that you can't access a potentially absent value without checking.
Constants and Static Variables
Constants are values that don't change and must be known at compile time:
const MAX_CONNECTIONS: u32 = 1000;
const PI: f64 = 3.14159265359;
Constants require type annotations and can't use mut. The compiler may inline constants wherever they're used, eliminating memory access entirely. Use constants for values that truly never change and have known values at compile time.
Static variables also represent unchanging values but have a fixed memory address:
static GLOBAL_COUNT: u32 = 0;
The difference is subtle. Constants can be inlined or duplicated. Static variables exist at a single location in memory. When you need a global variable that other code might reference by address, use static. For compile-time constants, use const.
Shadowing
Rust allows declaring a new variable with the same name as a previous variable. This shadows the earlier variable:
let x = 5;
let x = x + 1; // Shadows previous x
let x = x * 2; // Shadows again
println!("{}", x); // Prints 12
Shadowing differs from mutation. Each let creates a new variable. The earlier variable becomes inaccessible, but it never mutates. Shadowing lets you reuse variable names and change types:
let spaces = " "; // Type: &str
let spaces = spaces.len(); // Type: usize, shadows previous spaces
With mutation, you'd need a mutable variable and couldn't change types:
let mut spaces = " ";
spaces = spaces.len(); // Error: mismatched types
Shadowing provides flexibility while maintaining immutability's benefits for most variables.
Algebraic Data Types
Rust's type system incorporates algebraic data types—types built from product types and sum types. This terminology comes from mathematics and programming language theory, but the concepts are practical.
Product types combine multiple values. Tuples and structs are product types. A tuple (i32, bool) contains an i32 AND a bool. The number of possible values is the product of each component's possibilities.
Sum types represent alternatives. Enums are sum types. An Option<i32> contains an i32 OR nothing. The number of possible values is the sum of each variant's possibilities.
These types compose to build complex data structures with precise semantics:
// Product type: contains name AND age AND active status
struct User {
name: String,
age: u32,
active: bool,
}
// Sum type: represents success OR failure
enum Result<T, E> {
Ok(T),
Err(E),
}
Pattern matching exhaustively handles sum types, ensuring you account for all possibilities. The compiler checks that you've covered every variant. This prevents the bugs that occur when you forget to handle a case.
Why Strict Types Matter
Type safety catches errors at compile time that other languages only catch at runtime—if they catch them at all. When you declare that a function accepts an i32, the compiler ensures every call passes an i32. When you declare that a variable is Option<String>, the compiler ensures you handle the None case before accessing the string.
The strictness front-loads debugging. Your code must satisfy the type checker before it runs. This feels restrictive initially, especially if you're accustomed to dynamically typed languages. The benefit emerges during refactoring and maintenance. Change a function's signature, and the compiler identifies every call site that needs updating. Modify a data structure, and the compiler shows every location that makes invalid assumptions.
Types also serve as documentation. A function signature like fn process(input: &str) -> Result<i32, ParseError> tells you it accepts a string reference and returns either an integer or a parsing error. You don't need comments or documentation—the types communicate intent.
Rust's type system doesn't just prevent bugs. It enables features like zero-cost abstractions. Because the compiler knows exact types at compile time, it can generate optimal machine code. Generic functions specialized for concrete types compile to the same code you'd write manually. Type information disappears at runtime—the compiled program contains only machine code operating on bytes.
