Data Types
Every value in Rust has a type, and Rust decides that type at compile time, not while the program is running. This matters because the compiler uses type information to figure out exactly how much memory a value needs, which operations are legal on it, and whether your program is safe to run at all. In this lesson you’ll learn Rust’s built-in scalar and compound data types, how type inference and explicit annotations work together, and the mistakes beginners commonly make when Rust’s numeric types don’t behave like they expect from other languages.
Overview: How Rust’s Type System Works
Rust is statically typed: every variable’s type is fixed at compile time and never changes. This is different from Python or JavaScript, where a variable can hold an integer one moment and a string the next. In Rust, once x is bound to an i32, it stays an i32 for its entire lifetime — you cannot reassign it to a String later.
Rust is also strongly typed with almost no implicit conversion. In C, adding an int and a double silently promotes the int to a double. Rust refuses to do this. If you write let c = a + b; where a is i32 and b is f64, the compiler stops you with a type error, because Rust considers "quietly changing your data’s representation" a bug-prone shortcut, not a convenience. You must convert explicitly with the as keyword, so the conversion is visible in the code and impossible to miss during a review.
Rust’s built-in types fall into two families:
- Scalar types represent a single value: integers, floating-point numbers, booleans, and characters.
- Compound types group multiple values into one: tuples (fixed-size, mixed types) and arrays (fixed-size, one type).
A key idea to internalize now, because it underlies almost everything else in Rust: scalar types and fixed-size compound types have a size that is known at compile time. That’s what lets Rust store them directly on the stack, a region of memory that’s extremely fast to allocate from and free (it’s just moving a pointer up or down) — unlike heap-allocated types such as String or Vec<T>, whose size can change while the program runs and which therefore need a separate heap allocation. Understanding this stack/heap distinction now will make ownership, which you’ll meet in the next lesson, click much faster.
Most of the time you don’t have to write out a type explicitly — Rust’s compiler performs type inference, looking at how a value is used to figure out its type. But inference needs a starting point: numeric literals default to i32 and f64 unless something (an annotation, a function signature, a later use) tells the compiler otherwise.
Syntax
The general form for declaring a typed variable is:
let variable_name: Type = value;
let inferred_name = value; // type inferred from the value
let— introduces a new variable binding.variable_name— the identifier; bindings are immutable by default unless you writelet mut.: Type— an optional explicit type annotation. Required whenever the compiler cannot infer the type on its own (for example, after.parse(), which can produce many different types).= value— the initial value, which must match the annotated type exactly (no silent numeric conversion).
Scalar Types
| Category | Types | Notes |
|---|---|---|
| Signed integers | i8, i16, i32, i64, i128, isize |
i32 is the default; isize is pointer-sized (used for indexing). |
| Unsigned integers | u8, u16, u32, u64, u128, usize |
No sign bit — can never be negative; usize is what array/slice indices use. |
| Floating-point | f32, f64 |
f64 is the default and is the same speed as f32 on most modern CPUs. |
| Boolean | bool |
One byte, either true or false. |
| Character | char |
4 bytes, a single Unicode scalar value (not just ASCII); written with single quotes like 'A'. |
Compound Types
| Type | Example | Notes |
|---|---|---|
| Tuple | (i32, f64, bool) |
Fixed length, elements can be different types, accessed by destructuring or .0, .1, etc. |
| Array | [i32; 5] |
Fixed length known at compile time, all elements the same type, stored on the stack. |
Examples
Example 1: The Basic Scalar Types
fn main() {
let age: u32 = 30;
let pi: f64 = 3.14159;
let is_active: bool = true;
let grade: char = 'A';
println!("Age: {}", age);
println!("Pi: {}", pi);
println!("Active: {}", is_active);
println!("Grade: {}", grade);
}
Output:
Age: 30
Pi: 3.14159
Active: true
Grade: A
Each variable is given an explicit type annotation. Notice that grade uses single quotes for a char — double quotes would create a &str instead, which is a different type entirely and would not compile where a char is expected.
Example 2: Tuples and Arrays
fn main() {
let coordinates: (f64, f64, f64) = (10.5, 20.3, -5.0);
let (x, y, z) = coordinates;
println!("x = {}, y = {}, z = {}", x, y, z);
let numbers: [i32; 5] = [1, 2, 3, 4, 5];
println!("First number: {}", numbers[0]);
println!("Array length: {}", numbers.len());
let sum: i32 = numbers.iter().sum();
println!("Sum: {}", sum);
}
Output:
x = 10.5, y = 20.3, z = -5
First number: 1
Array length: 5
Sum: 15
The tuple coordinates is destructured into three separate variables in one line with let (x, y, z) = coordinates;. The array numbers has a fixed length of 5 baked into its type, [i32; 5], so numbers.len() is actually known at compile time. Note that Rust’s default float formatting drops a trailing .0, which is why -5.0 prints as -5.
Example 3: Parsing, Casting, and String Types Together
fn main() {
let quantity_str = "42";
let quantity: i32 = quantity_str.parse().expect("not a number");
let price: f64 = 19.99;
let total: f64 = quantity as f64 * price;
println!("Quantity: {}", quantity);
println!("Total: {:.2}", total);
let name: &str = "Rust";
let greeting: String = format!("Hello, {}!", name);
println!("{}", greeting);
}
Output:
Quantity: 42
Total: 839.58
Hello, Rust!
This example mixes several ideas: quantity_str.parse() converts a &str into an i32 (the target type comes from the let quantity: i32 annotation), quantity as f64 explicitly casts an integer to a float so it can be multiplied by price, and {:.2} in the format string rounds the output to two decimal places. name is a borrowed string slice (&str), while greeting is an owned, heap-allocated String built from it with format!. Use &str for parameters that only need to read text, and String when you need to own or grow the text.
How It Works Step by Step
- When the compiler sees
let age: u32 = 30;, it checks that the literal30fits inside the range ofu32(0 to 4,294,967,295). This check happens at compile time for literals. - Because
u32,f64,bool, andcharall have a fixed, known size (4, 8, 1, and 4 bytes respectively), the compiler reserves that exact amount of space on the stack frame formain. No heap allocation is needed. - For the array
[i32; 5], the compiler computes the total size as 5 × 4 bytes = 20 bytes and lays the elements out contiguously, which is why indexing withnumbers[0]is a simple, constant-time memory offset calculation. - When you write
quantity as f64, the compiler emits a numeric conversion instruction at that exact point — there’s no hidden coercion elsewhere in the expression, which is why Rust required you to writeas f64explicitly in the first place. .parse()returns aResult<i32, ParseIntError>; calling.expect("not a number")unwraps theOkvalue or panics with your message if parsing failed (for example, if the string were"abc").
Common Mistakes
Mistake 1: Integer Literal Out of Range
A u8 can only hold values from 0 to 255. Assigning a literal outside that range is caught at compile time:
fn main() {
let small_number: u8 = 300;
println!("{}", small_number);
}
The compiler rejects this with an error like literal out of range for `u8`, because 300 cannot be represented in 8 bits. Fix it by widening the type to something that can hold 300, such as u16:
let small_number: u16 = 300;
println!("{}", small_number);
Output:
300
Mistake 2: Mixing Numeric Types Without Casting
Rust never implicitly converts between numeric types, even when the conversion looks "obviously safe" to a human:
fn main() {
let a: i32 = 5;
let b: f64 = 2.5;
let c = a + b;
println!("{}", c);
}
This fails with mismatched types, because + requires both operands to be the same type, and i32 is not f64. The fix is an explicit cast with as:
let a: i32 = 5;
let b: f64 = 2.5;
let c = a as f64 + b;
println!("{}", c);
Output:
7.5
Mistake 3: Confusing char with a One-Letter String
Single and double quotes are not interchangeable in Rust — they select entirely different types:
fn main() {
let initial: char = "A";
println!("{}", initial);
}
This fails because "A" (double quotes) has type &str, not char. Use single quotes for a single character:
let initial: char = 'A';
println!("{}", initial);
Output:
A
Best Practices
- Let type inference do the work for local variables when the type is obvious from the value or its later use; add an explicit annotation only when the compiler asks for one or when it makes the code clearer to a reader.
- Prefer
i32for general-purpose integer math unless you have a specific reason (indexing needsusize, a value must never be negative, or you need a wider range) — it’s the fastest integer type on most platforms and Rust’s own default. - Use
usizefor array/slice indices and lengths; the standard library requires it, and mixing index types forces extra casts. - Take
&strparameters in functions that only read string data, and return or storeStringwhen the function needs to own or build text — this keeps APIs flexible and avoids unnecessary allocations. - Remember that arithmetic overflow panics in debug builds and silently wraps in release builds; if wrapping or saturating behavior is what you actually want, use explicit methods like
wrapping_addorsaturating_addinstead of relying on default+. - Reach for
ascasts deliberately and sparingly — narrowing casts (likei32tou8) silently truncate data rather than erroring, so double-check the value’s range before casting down.
Practice Exercises
- Declare three variables for a person’s
age(asu8),height_m(asf64), andis_student(asbool), then print them in one formatted sentence usingprintln!. - Create a tuple representing an RGB color as three
u8values (for example,(255, 99, 71)), destructure it intor,g, andb, and print each component on its own line. - Create an array of 5
i32exam scores, compute their average using.iter().sum()divided by the array’s length (cast tof64), and print the result with two decimal places using{:.2}. Expected output for[85, 90, 78, 92, 88]is86.60.
Summary
- Rust is statically and strongly typed: every value’s type is fixed at compile time, and there is almost no implicit conversion between types.
- Scalar types (integers, floats,
bool,char) and fixed-size compound types (tuples, arrays) have a size known at compile time, so they can be stored directly on the stack. - Integer types are named by signedness and bit width (
i32,u8, and so on);f64andi32are the defaults when the compiler needs to pick a type for you. - Tuples group a fixed number of values of possibly different types; arrays group a fixed number of values of the same type.
- Convert between numeric types explicitly with
as; convert strings to numbers with.parse(), guided by a type annotation. Stringis an owned, growable, heap-allocated string;&stris a borrowed view into string data — choose based on whether the code needs to own or just read the text.
