Beginner

Variables and Types in Rust

Learn about variable bindings, primitive types, mutability, type inference, and type casting in Rust with Docker-ready examples

Rust’s type system is one of its defining features—static, strong, and powerful enough to catch entire classes of bugs at compile time. But it’s also ergonomic: the compiler infers types in most situations, so you don’t have to write type annotations everywhere.

The most important thing to understand about Rust variables is that they are immutable by default. This isn’t just a convention or a linter warning—mutating an immutable binding is a compile error. This design forces you to be intentional about state changes, which makes code easier to reason about and eliminates a common source of bugs.

Rust’s type system reflects its systems-programming heritage: you have precise control over integer widths and signedness, there are two distinct string types with different ownership semantics, and every type conversion must be explicit. The compiler never silently coerces one type into another.

In this tutorial you’ll learn how to declare variables and constants, explore Rust’s primitive types, understand the difference between let and let mut, use type inference and explicit annotations, cast between types, and work with compound types like tuples and arrays.

Bindings, Mutability, and Primitive Types

Rust calls variable declarations bindings because let binds a name to a value. By default, that binding is immutable.

Create a file named variables.rs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
fn main() {
    // Immutable binding — the default in Rust
    let x = 5;
    let y: f64 = 3.14;
    let is_active: bool = true;
    let letter: char = 'R';

    println!("Integer: {}", x);
    println!("Float: {}", y);
    println!("Boolean: {}", is_active);
    println!("Char: {}", letter);

    // Mutable binding — requires explicit 'mut'
    let mut count = 0;
    count += 1;
    count += 1;
    println!("Count: {}", count);

    // Constants require an explicit type and are always immutable.
    // Underscores in numeric literals improve readability.
    const MAX_POINTS: u32 = 100_000;
    println!("Max points: {}", MAX_POINTS);

    // Rust provides size-specific integer types.
    // Signed:   i8, i16, i32, i64, i128, isize
    // Unsigned: u8, u16, u32, u64, u128, usize
    let small: i8 = 127;
    let big: i64 = 9_223_372_036_854_775_807;
    let unsigned: u32 = 4_294_967_295;
    println!("i8 max: {}", small);
    println!("i64 max: {}", big);
    println!("u32 max: {}", unsigned);
}

Strings, Shadowing, Type Casting, and Compound Types

Rust has two string types. &str is a string slice—an immutable reference to UTF-8 data stored somewhere (often in the compiled binary). String is a heap-allocated, owned, growable string. This distinction matters because of ownership: a String can be moved and mutated; a &str is just a view.

Shadowing lets you reuse a name in the same scope with a new binding. Unlike mutability, shadowing can change the type of a binding and works with immutable let.

Create a file named variables_types.rs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
fn main() {
    // &str: a string slice (borrowed reference, stored in the binary)
    // String: heap-allocated, owned string
    let greeting: &str = "Hello, Rust!";
    let owned: String = String::from("I own this string");
    println!("{}", greeting);
    println!("{}", owned);

    // Shadowing: rebind a name with a new value — or a completely new type.
    // This is different from mutation: each 'let' creates a new binding.
    let spaces = "   ";
    let spaces = spaces.len();  // 'spaces' is now a usize, not a &str
    println!("Spaces: {}", spaces);

    // Type casting uses the 'as' keyword. Rust never coerces silently.
    let integer: i32 = 42;
    let as_float: f64 = integer as f64;
    let truncated: i32 = 3.99_f64 as i32;  // truncates toward zero, does not round
    println!("i32 as f64: {}", as_float);
    println!("3.99 as i32: {}", truncated);

    // Tuples group values of different types. Access elements with .0, .1, etc.
    let point: (f64, f64) = (1.5, 2.7);
    let rgb: (u8, u8, u8) = (255, 128, 0);
    println!("Point: ({}, {})", point.0, point.1);
    println!("RGB: ({}, {}, {})", rgb.0, rgb.1, rgb.2);

    // Arrays hold a fixed number of values of the same type.
    // The type annotation [T; N] specifies element type and length.
    let primes: [i32; 5] = [2, 3, 5, 7, 11];
    println!("First prime: {}", primes[0]);
    println!("Array length: {}", primes.len());
}

Running with Docker

1
2
3
4
5
6
7
8
# Pull the official Rust image
docker pull rust:1.83

# Compile and run the first example
docker run --rm -v $(pwd):/app -w /app rust:1.83 sh -c 'rustc variables.rs && ./variables'

# Compile and run the second example
docker run --rm -v $(pwd):/app -w /app rust:1.83 sh -c 'rustc variables_types.rs && ./variables_types'

Expected Output

Running variables.rs:

Integer: 5
Float: 3.14
Boolean: true
Char: R
Count: 2
Max points: 100000
i8 max: 127
i64 max: 9223372036854775807
u32 max: 4294967295

Running variables_types.rs:

Hello, Rust!
I own this string
Spaces: 3
i32 as f64: 42
3.99 as i32: 3
Point: (1.5, 2.7)
RGB: (255, 128, 0)
First prime: 2
Array length: 5

Key Concepts

  • Immutable by defaultlet x = 5 creates a binding that cannot be reassigned; use let mut when you need to change a value. This is enforced by the compiler, not just a convention.
  • Type inference — Rust infers types from context in most situations. You can omit the annotation (let x = 5) or be explicit (let x: i32 = 5); both are valid and the compiler checks both.
  • Constants vs. immutable bindingsconst is evaluated at compile time, requires an explicit type, and can be declared in any scope including global. Immutable let bindings are evaluated at runtime and are scoped to their block.
  • Size-specific integers — Rust offers i8 through i128 and u8 through u128, plus isize/usize (pointer-sized). Choosing the right width is part of systems-level programming; the default integer type is i32.
  • Two string types&str is a borrowed slice (cheap, no allocation); String is an owned heap value (flexible, growable). Most string literals are &str; functions that need to modify or own a string use String.
  • Shadowing changes the binding, not the valuelet spaces = spaces.len() creates a new usize binding named spaces, leaving the original &str binding consumed. This is useful for transforming a value through different types in sequence.
  • Explicit casts only — Rust never performs implicit numeric coercions. Use as to cast between numeric types; be aware that casting a float to an integer truncates (it does not round).
  • Tuples and arrays are fixed-size — Tuples hold heterogeneous types; arrays hold a single type. Both sizes are known at compile time and live on the stack. For dynamically sized collections, use Vec<T>.

Running Today

All examples can be run using Docker:

docker pull rust:1.83
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining