Skip to content
C Codeloom
Rust

Variables and Basic Types in Rust

A practical tour of Rust variables and scalar types — let, mut, shadowing, integers, floats, booleans, chars, tuples, arrays, type inference, and constants. With runnable examples.

·9 min read · By Yash Kesharwani
Beginner 11 min read

What you'll learn

  • How to declare variables with let, and when to add mut
  • Why shadowing exists and how it differs from mutation
  • The scalar types: i32, u32, f64, bool, char — and when to pick each
  • How tuples and arrays differ from each other and from Vec
  • When to annotate types and when to let the compiler infer them
  • How constants differ from let bindings

Prerequisites

Rust gives you a small set of built-in types and asks you to be precise about which one you mean. This precision is the price of the safety guarantees the compiler offers. Once it becomes habit, you will appreciate the clarity it forces on every line.

This post covers everything you need to declare and use values for the rest of the series.

let declares a binding

The keyword is let. The name comes first, then optionally a type, then the value:

fn main() {
    let x = 5;
    println!("x = {}", x);
    // output: x = 5
}

Rust inferred that x is an i32 (a 32-bit signed integer — the default integer type). You can spell it out:

let x: i32 = 5;

Both forms are equivalent. The convention is to omit the type when it’s obvious and add it when it isn’t.

Bindings are immutable by default

This is the first thing that surprises newcomers. Try to reassign:

fn main() {
    let x = 5;
    x = 6; // error
}

The compiler refuses:

cargo run
// output:
// error[E0384]: cannot assign twice to immutable variable `x`

In Rust, let creates an immutable binding. The compiler trusts that the value won’t change, and so do future readers of your code. If you want mutation, opt in explicitly:

fn main() {
    let mut x = 5;
    println!("x = {}", x);   // x = 5
    x = 6;
    println!("x = {}", x);   // x = 6
}

mut is a signal to anyone reading the code: “this will change.” Use it deliberately. Most variables in idiomatic Rust are not mut.

Shadowing

Rust also lets you declare a new binding with the same name. This is called shadowing and it is different from mutation:

fn main() {
    let x = 5;
    let x = x + 1;
    let x = x * 2;
    println!("x = {}", x);
    // output: x = 12
}

Each let x is a brand-new variable that happens to reuse the name. The old binding goes out of scope. A useful side effect is that the new binding can have a different type:

let spaces = "   ";          // &str
let spaces = spaces.len();   // usize

You cannot do this with mut — mutating a variable’s type is forbidden. Shadowing is the right tool when you want to transform a value and don’t need the old one any more.

Constants

A constant is a value bound to a name at compile time. Use const, always with an explicit type, and ALL_CAPS by convention:

const MAX_RETRIES: u32 = 5;
const PI: f64 = 3.14159;

fn main() {
    println!("max retries: {}", MAX_RETRIES);
}

Constants differ from let bindings in three ways:

  • They must have an explicit type.
  • Their value must be a constant expression (no function calls that aren’t const fn).
  • They can be declared at the top level, outside any function. let cannot.

Use const for fixed values used across your crate. Use let for everything else.

Scalar types: the integer family

Rust has signed and unsigned integers in sizes i8/u8 through i128/u128, plus isize and usize which match the machine word (64 bits on modern hardware). usize is what indexing returns from arrays and Vec.

The default integer type is i32. Pick it unless you have a reason not to. Use u32 or u64 for things that can’t be negative (counts, sizes). Use usize when indexing a collection.

let a: i32 = -10;
let b: u64 = 9_000_000_000;     // underscores are visual separators
let c = 0xff;                   // hex
let d = 0b1010_0101;            // binary
let e = b'A';                   // a byte literal, type u8 (value 65)

Underscores are visual separators only.

Overflow

In debug mode, Rust panics on integer overflow; in release mode, it wraps silently for performance. If you want a specific behaviour, use the explicit methods: wrapping_add, checked_add, saturating_add, or overflowing_add.

Floating-point numbers

Two types: f32 and f64. The default — and the one you should reach for unless memory is tight — is f64.

let pi = 3.14159;          // f64
let half: f32 = 0.5;

println!("{} {}", pi, half);

Floats follow IEEE 754. Be aware of the usual caveats: 0.1 + 0.2 != 0.3, and there are bit patterns like NaN and infinity from operations like divide-by-zero. For money and other exact decimals, use a crate like rust_decimal rather than floats.

Booleans

The type is bool, with values true and false:

let is_ready: bool = true;
let parsed = "5".parse::<i32>().is_ok(); // also bool

bool cannot be implicitly converted to or from an integer — there is no truthiness in Rust. Either you have a bool or you don’t.

Characters

A char is a Unicode scalar value, four bytes wide, written with single quotes:

let letter: char = 'A';
let emoji: char = '🦀';
let kanji: char = '猫';

Don’t confuse char with &str or String. The string types are sequences of UTF-8 bytes, while char is one Unicode scalar value — which may be one to four bytes when encoded as UTF-8. This is a real distinction Rust forces you to keep straight.

Tuples

A tuple is a fixed-size, heterogeneous group of values:

let pair: (i32, f64) = (42, 3.14);
let (n, f) = pair;                  // destructure
println!("{} {} {}", n, f, pair.0); // access by index too
// output: 42 3.14 42

Tuples are useful for returning multiple values from a function — see Functions in Rust — and for grouping a small number of related values without naming a struct. Once you find yourself with more than three fields, switch to a struct.

The empty tuple () is called the unit type. It is Rust’s “nothing” — the return type of functions that don’t return a value.

Arrays

An array is a fixed-size, homogeneous sequence stored on the stack:

let primes: [i32; 5] = [2, 3, 5, 7, 11];
let zeros = [0u8; 1024];        // 1024 zero bytes

println!("first: {}", primes[0]);    // first: 2
println!("len: {}", primes.len());   // len: 5

The type is written as [T; N] where N is part of the type. [i32; 5] and [i32; 6] are different types — the size is fixed at compile time.

Indexing past the end panics:

let _ = primes[10]; // panic: index out of bounds

For a growable, heap-allocated sequence, use Vec<T> — which we cover in a later post. Use arrays when the size is known at compile time and small enough to live on the stack.

Type inference, in practice

Rust’s type inference is strong enough that you can usually skip annotations. But sometimes the compiler needs help:

let parsed = "42".parse(); // error: cannot infer type

Two ways to fix it. Annotate the binding:

let parsed: i32 = "42".parse().unwrap();

Or annotate the call with the turbofish:

let parsed = "42".parse::<i32>().unwrap();

Both produce the same machine code. Pick whichever reads better at the site. The turbofish is especially useful when the result is being chained immediately.

Try this. In a fresh Cargo project, write a main that:

  1. Declares an array of five f64 temperatures.
  2. Computes their average and binds it to a let with no annotation.
  3. Shadows the average with a String containing the formatted result like "avg = 21.4".
  4. Prints the string.

You should not need to write mut anywhere.

Conversions are explicit

Rust will never silently convert between numeric types. Even i32 to i64 requires an explicit cast with as:

let a: i32 = 100;
let b: i64 = a as i64;
let c: f64 = a as f64;

Silent integer conversion is the source of countless bugs in C; Rust treats it as a place you must look at on purpose. For checked, well-typed conversions, the From and TryFrom traits are the idiomatic choice — but those need ownership and traits, covered in Rust Ownership Basics and later.

Putting it together

A small program that exercises everything from this post:

const SAMPLE_COUNT: usize = 4;

fn main() {
    let samples: [f64; SAMPLE_COUNT] = [12.0, 15.5, 9.25, 18.75];
    let mut total = 0.0;
    for s in samples {
        total += s;
    }
    let average = total / SAMPLE_COUNT as f64;
    println!("average = {:.2}", average);
    // output: average = 13.88
}

The as f64 cast is required — SAMPLE_COUNT is a usize and you cannot divide a f64 by a usize without an explicit conversion.

Recap

You now know:

  • let declares an immutable binding; opt into mutation with mut.
  • Shadowing lets you reuse a name with a new value (and even a new type), while mut can only change the value.
  • The default integer type is i32; the default float is f64; bool and char round out the scalars.
  • Tuples group fixed-size heterogeneous values; arrays are fixed-size, same-type, stack-allocated sequences.
  • Type inference handles most cases; annotate when the compiler asks.
  • Numeric conversions are always explicit via as or the From/TryFrom traits.

Next steps

The next post is about functions — the unit of reuse in Rust. It covers the fn syntax, parameters, return values, and the expression-vs-statement rule that explains why Rust functions look the way they do.

→ Next: Functions in Rust

Questions or feedback? Email codeloomdevv@gmail.com.