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 write let 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

  1. When the compiler sees let age: u32 = 30;, it checks that the literal 30 fits inside the range of u32 (0 to 4,294,967,295). This check happens at compile time for literals.
  2. Because u32, f64, bool, and char all have a fixed, known size (4, 8, 1, and 4 bytes respectively), the compiler reserves that exact amount of space on the stack frame for main. No heap allocation is needed.
  3. 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 with numbers[0] is a simple, constant-time memory offset calculation.
  4. 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 write as f64 explicitly in the first place.
  5. .parse() returns a Result<i32, ParseIntError>; calling .expect("not a number") unwraps the Ok value 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 i32 for general-purpose integer math unless you have a specific reason (indexing needs usize, 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 usize for array/slice indices and lengths; the standard library requires it, and mixing index types forces extra casts.
  • Take &str parameters in functions that only read string data, and return or store String when 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_add or saturating_add instead of relying on default +.
  • Reach for as casts deliberately and sparingly — narrowing casts (like i32 to u8) silently truncate data rather than erroring, so double-check the value’s range before casting down.

Practice Exercises

  1. Declare three variables for a person’s age (as u8), height_m (as f64), and is_student (as bool), then print them in one formatted sentence using println!.
  2. Create a tuple representing an RGB color as three u8 values (for example, (255, 99, 71)), destructure it into r, g, and b, and print each component on its own line.
  3. Create an array of 5 i32 exam scores, compute their average using .iter().sum() divided by the array’s length (cast to f64), and print the result with two decimal places using {:.2}. Expected output for [85, 90, 78, 92, 88] is 86.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); f64 and i32 are 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.
  • String is an owned, growable, heap-allocated string; &str is a borrowed view into string data — choose based on whether the code needs to own or just read the text.