You're building a game, and the compiler stops you
You're writing a simple 2D game in Rust. You need to track a player's position, their health, and whether they're holding a sword. In Python, you'd slap these into a dictionary or a class and move on. The interpreter handles the memory. In Rust, the compiler asks for specifics. It wants to know exactly how much memory to reserve and how to pack these values so the CPU can grab them fast.
This is where data types come in. They aren't just labels. They are blueprints for memory. Rust splits types into two camps: scalar types for single values and compound types for groups. Getting these right gives you performance and safety. Getting them wrong triggers the borrow checker or causes runtime panics.
Pick the type that matches the shape of your data. The compiler will enforce the rest.
Scalar types: the atomic units
Scalar types hold a single value. Think of them as indivisible blocks. Rust has four scalar types: integers, floats, booleans, and characters. Each one has a specific size and behavior. The compiler uses this information to lay out memory precisely.
Integers: size and sign matter
Rust offers a family of integer types. You choose the size in bits and whether the value can be negative.
fn main() {
// i32 is the default integer. Signed, 32-bit. Good for general counting and arithmetic.
let score: i32 = 42;
// u8 is unsigned, 8-bit. Perfect for byte-sized data like color channels or small flags.
let red_channel: u8 = 255;
// i64 is signed, 64-bit. Use this when you need a larger range than i32 provides.
let timestamp: i64 = 1700000000000;
// usize matches the platform's pointer width. Always use this for indexing collections.
let index: usize = 0;
}
The naming follows a pattern. i means signed, u means unsigned. The number is the bit width. i8 ranges from -128 to 127. u8 ranges from 0 to 255. i32 is the default when you write a plain integer literal. The compiler infers i32 unless you specify otherwise.
Convention aside: Use usize for all indices and lengths. usize adapts to the architecture. It is 32 bits on 32-bit systems and 64 bits on 64-bit systems. Using usize prevents overflow when indexing large collections on modern hardware.
Integers in Rust have strict overflow behavior. In debug mode, arithmetic that exceeds the type's range causes a panic. In release mode, it wraps around. This is different from C, where overflow is undefined behavior, and Python, where integers grow automatically. Rust forces you to handle the boundary.
fn main() {
// i32::MAX is 2,147,483,647.
let max = i32::MAX;
// In debug mode, this panics. In release mode, it wraps to i32::MIN.
// Use checked_add to handle overflow gracefully without panicking.
let result = max.checked_add(1);
// result is None here. You can match on it to handle the error.
match result {
Some(val) => println!("Safe: {}", val),
None => println!("Overflow detected"),
}
}
Don't guess the size. If you're indexing a collection, use usize. If you're counting bytes, use u8. Let the type tell the story.
Floats, booleans, and the tricky char
Floats follow the IEEE-754 standard. Rust provides f32 and f64. f64 is the default. Floats are great for scientific calculations and graphics, but they have precision quirks. Two floats that look equal might differ by a tiny epsilon. Never compare floats with == for equality checks in production code. Use a tolerance range instead.
fn main() {
// f64 is the default float. 64-bit precision. Use this unless profiling shows f32 is enough.
let pi: f64 = 3.14159;
// bool is strictly true or false. No 0/1 tricks. Rust won't coerce integers to booleans.
let has_sword: bool = true;
// char is a Unicode scalar value. It's 4 bytes, not 1. It handles emojis and global scripts.
let emoji: char = '🦀';
}
Booleans are straightforward. true and false. Rust does not allow implicit conversion from integers to booleans. You cannot write if 1 { ... }. You must write if value != 0 { ... }. This prevents a common class of bugs where a developer forgets the comparison operator.
char deserves special attention. In many languages, a character is a single byte. In Rust, char is a Unicode scalar value. It occupies 4 bytes. It can represent any character in the Unicode standard, including emojis, CJK scripts, and accented characters. This makes char safe for text processing but expensive for byte buffers.
Convention aside: When working with raw bytes or network protocols, use u8. When working with user-facing text or individual characters, use char. Mixing them up leads to encoding errors. The community calls this the "byte vs char" distinction. Keep bytes in u8 and characters in char.
Trust the 4-byte size. It prevents encoding bugs before they happen.
Compound types: grouping values
Compound types group multiple values together. Rust has two compound types: tuples and arrays. They serve different purposes. Tuples bundle different types. Arrays bundle the same type.
Tuples: fixed bundles of mixed types
A tuple is a fixed-length sequence of values with potentially different types. You define a tuple by listing values inside parentheses. You access elements by index using dot notation.
fn main() {
// Tuple: fixed length, mixed types. Great for returning multiple values from a function.
let player_state: (i32, bool) = (100, true);
// Access elements by index. Indices start at 0.
let health = player_state.0;
let has_sword = player_state.1;
// Destructure the tuple into named variables. This is the idiomatic way to use tuples.
let (current_health, sword_flag) = player_state;
}
Tuples shine when you need to return multiple values from a function. They are also useful for grouping related data temporarily. You can destructure a tuple to extract its parts into separate variables. Destructuring makes code readable and avoids repetitive index access.
Convention aside: When a tuple has more than three elements, reach for a struct. Tuples are for small, transient groupings. Structs provide named fields, which improve readability and maintainability. The community treats tuples as "anonymous structs" for quick bundling.
Tuples are distinct types based on their element types and length. (i32, bool) is different from (bool, i32). The compiler enforces this strictly. You cannot assign a tuple of one shape to a variable expecting another shape.
Tuples are for quick bundles. If your tuple grows past three elements, reach for a struct.
Arrays: fixed collections on the stack
An array is a fixed-length collection of values with the same type. You define an array by listing values inside square brackets, followed by a semicolon and the length. Arrays are stored on the stack. Their size is known at compile time.
fn main() {
// Array: fixed length, same type. Stored on the stack. Size is known at compile time.
let inventory: [u8; 4] = [1, 2, 3, 4];
// Access elements by index. Rust checks bounds at runtime.
let first_item = inventory[0];
// Arrays have a len method. It returns usize.
let count = inventory.len();
// You can create an array with repeated values using [value; count] syntax.
let zeros: [i32; 5] = [0; 5];
}
Arrays provide zero-cost abstraction. There is no heap allocation. No pointer indirection. The data lives inline. This makes arrays incredibly fast for small, fixed collections. The compiler can optimize array access aggressively because the size is constant.
Array types include the length. [i32; 3] is a different type from [i32; 4]. This allows the compiler to catch size mismatches at compile time. If a function expects an array of length 3, you cannot pass an array of length 4.
Convention aside: Use arrays when the number of elements is known and small. For dynamic collections that grow or shrink, use Vec<T>. Arrays are fixed by design. Vec is the standard library's growable vector. The choice between array and Vec is a choice between fixed and dynamic.
Arrays give you speed and predictability. If you need a dynamic size, switch to Vec.
Realistic example: parsing sensor data
Imagine you're processing a batch of sensor readings. Each reading consists of a timestamp and a temperature value. You have exactly three readings. You want to store them efficiently and extract the first reading.
fn main() {
// Realistic scenario: processing a batch of sensor readings.
// Each reading is a tuple of (timestamp, value).
// The array holds exactly three readings.
let readings: [(u64, f64); 3] = [
(1000, 23.5),
(1001, 23.6),
(1002, 23.4),
];
// Destructure the first reading.
let (first_time, first_val) = readings[0];
println!("First reading: {} at {}", first_val, first_time);
// Iterate over the array. Arrays implement IntoIterator.
for (time, val) in readings {
println!("Time: {}, Value: {}", time, val);
}
}
This example combines arrays and tuples. The array provides a fixed container. The tuples provide structure for each element. Destructuring makes the data accessible. The code is concise and safe. The compiler knows the exact memory layout. It can optimize the loop and the access patterns.
Arrays give you speed and predictability. Tuples give you structure without ceremony. Combine them when you need a fixed batch of records.
Pitfalls and compiler errors
Rust's type system catches mistakes early. You will encounter errors when types don't match. The most common error is E0308 (mismatched types). This happens when you assign a value of one type to a variable expecting another type.
fn main() {
// This causes E0308: mismatched types.
// The compiler expects i32 but finds u8.
let score: i32 = 255u8;
}
The compiler rejects this with E0308. It tells you the expected type and the found type. You must convert explicitly. Rust does not perform implicit coercion between numeric types. You can use the as keyword for casting, but be aware that casting can truncate data.
fn main() {
// Explicit cast using as.
// This truncates if the value doesn't fit.
let score: i32 = 255u8 as i32;
}
Convention aside: Use as casting sparingly. It can silently drop bits. Prefer safe conversion methods like TryFrom or checked_cast when available. The community treats as as a "I know what I'm doing" signal. Use it only when you have verified the range.
Another pitfall is array bounds checking. Accessing an index out of range causes a runtime panic. Rust does not allow silent overflow like C. The panic stops the program, preventing memory corruption.
fn main() {
let arr: [i32; 3] = [1, 2, 3];
// This panics at runtime with "index out of bounds".
// Rust checks bounds before access.
let val = arr[5];
}
The panic message is clear. It tells you the index and the length. This makes debugging straightforward. You can use .get(index) to return an Option instead of panicking. This allows graceful handling of missing indices.
The compiler catches type mismatches before you run. Trust E0308. It saves you from silent data corruption.
Decision: picking the right type
Rust gives you fine-grained control. Choose types based on your data's characteristics.
Use i32 for general-purpose integers when you need signed values and don't have extreme range requirements. Use u8 for byte-sized data like color channels, flags, or small counters where memory footprint matters. Use usize for indexing collections and pointer arithmetic; it matches the architecture's address width. Use char when you need to represent a single Unicode character, including emojis and non-ASCII scripts. Use tuples when you need to group a small number of related values with different types, especially for function return values. Use arrays when you have a fixed number of elements of the same type and want stack allocation with zero overhead. Reach for structs when your data grouping needs named fields or grows beyond three elements. Reach for Vec<T> when the size must change at runtime; arrays are fixed by design.
Counter-intuitive but true: the more specific you are with types, the easier the compiler helps you. Vague types lead to vague errors.