Variables, Data Types, and Functions in Rust

I walk through Rust's immutable-by-default variables, shadowing, scalar and compound types, and the expression rule that defines how functions return values.

Variables, Data Types, and Functions in Rust

TypeScript’s let gives you a mutable variable. Rust’s let gives you the opposite — an immutable binding. That reversal surprised me the first time. The keyword that means mutable in JavaScript and TypeScript creates something you can’t reassign in Rust. Mutability requires a second keyword: mut.

That’s not just a quirk of syntax. The mutability-by-default decision connects directly to how the type system works and to the rule that governs how functions return values. These three topics — variables, types, and functions — are where every Rust program actually starts, and understanding them together shows how the language fits into a consistent shape.

Series: Part 4 of 12. Start with Part 1: Learning Rust as a TypeScript Developer · Previous: Ownership and Borrowing in Rust Explained Simply

On this page

Variables are immutable by default

In TypeScript, you pick between const (immutable binding, mutable value) and let (mutable binding). In Rust, let creates an immutable binding by default. To allow reassignment you add mut:

// TypeScript
let count = 0;
count = 1; // fine — let is mutable

const score = 0;
score = 1; // TypeError: Assignment to constant variable
fn main() {
    let count = 0;
    count = 1; // error[E0384]: cannot assign twice to immutable variable

    let mut score = 0;
    score = 1; // fine
}

The compiler error for the first case is specific and helpful:

error[E0384]: cannot assign twice to immutable variable `count`
  --> src/main.rs:3:5
   |
2  |     let count = 0;
   |         -----
   |         first assignment to `count`
   |         help: consider making this binding mutable: `mut count`
3  |     count = 1;
   |     ^^^^^^^^^ cannot assign twice to immutable variable

The Rust Book explains the design choice directly: when a value is immutable, the compiler can guarantee it won’t change between the moment you write it and the moment you read it. That guarantee is especially useful in concurrent code — if a value can’t be mutated, you don’t need locks or careful sequencing around it.

For TypeScript developers, the closest intuition is const on primitives, but stricter. TypeScript’s const on an array still lets you push to it. Rust’s let without mut prevents any change to the value.

Try this

  1. Create a new project with cargo new variables_demo and open src/main.rs.
  2. Write let x = 5; then x = 10; and run cargo build.
  3. Read the error. Then add mut to the declaration and build again.

Expected result: The first build fails with E0384. After adding mut, it compiles cleanly. The compiler’s suggestion (“consider making this binding mutable: mut count”) appears in the first error output.

Shadowing: a new binding, not a mutation

Rust lets you declare a new variable with the same name as an existing one. The new binding shadows the old one — it doesn’t modify it, it replaces it within the current scope. This is different from mut in two important ways: the original variable is gone, and the new one can have a completely different type.

fn main() {
    let spaces = "   "; // &str
    let spaces = spaces.len(); // usize — new binding that shadows the first
    println!("{}", spaces); // prints 3
}

Compare what happens if you try the same thing with mut:

fn main() {
    let mut spaces = "   "; // &str
    spaces = spaces.len(); // error: mismatched types — can't assign usize to &str
}

With mut, you can reassign a variable but you’re locked to its original type. Shadowing creates a genuinely new variable — the old one ceases to exist in that scope, and the new one can have whatever type you need.

In TypeScript you can shadow variables across block scopes, but you can’t redeclare a const or let at the same scope level. In Rust, redeclaring with let at the same level is intentional and useful. The Rust Book specifically notes that shadowing lets you apply a series of transformations — parsing a string to a number, trimming whitespace, computing a derived value — while keeping the result immutable after each step.

Scalar types and their TypeScript equivalents

Rust has four scalar types: integers, floating-point numbers, booleans, and characters. TypeScript compresses most of these into number and string.

Integers. Rust exposes the storage size directly: i8, i16, i32, i64, i128 (signed), and u8, u16, u32, u64, u128 (unsigned). The suffix is the number of bits. The default when you write an integer literal without annotation is i32.

let age: u8 = 30;              // unsigned 8-bit: valid range 0–255
let year: i32 = 2025;          // signed 32-bit (the default)
let big: i64 = 9_000_000_000;  // underscores improve readability

In TypeScript, number is always a 64-bit float under the hood. You don’t choose a size. In Rust, you choose the size that matches your problem — an i32 for a loop counter, a u8 for a byte value, an i64 for a large timestamp. Choosing too small a type is a compile-time decision you make once; in debug builds, integer overflow causes a panic instead of silently wrapping.

Floating-point. f32 (32-bit) and f64 (64-bit). TypeScript’s number maps directly to f64. Rust’s default is also f64 when you write a decimal literal without an annotation.

let ratio = 0.75;       // f64 inferred
let scale: f32 = 0.5;   // f32 explicit

Booleans. Same as TypeScript: bool with values true and false. The difference is strict type enforcement — if 1 {} is a compile error in Rust. There’s no implicit coercion from integers or any other type to bool.

let is_ready: bool = true;
let result = 5 > 3; // bool inferred as true

Characters. Rust’s char is a Unicode scalar value stored in four bytes. It always represents one complete Unicode code point. In TypeScript, characters are just single-element strings; in Rust, char is a distinct first-class type, and it uses single quotes.

let letter = 'R';           // char
let crab: char = '\u{1F980}'; // the crab emoji via Unicode escape

The char type matters when you’re working with text at the character level — iterating over a string in Rust gives you char values, not byte indices.

Compound types: tuples and arrays

Rust groups multiple values into two primitive compound types: tuples and arrays.

Tuples hold values of different types in a fixed-length sequence. The type signature encodes both the count and the type of each position.

let point: (i32, i32) = (10, 20);
let mixed: (u8, f64, bool) = (255, 3.14, true);

// Destructuring into individual variables
let (x, y) = point;
println!("x: {x}, y: {y}");

// Index access with dot notation
println!("{}", mixed.0); // 255

TypeScript has tuple types too: const point: [number, number] = [10, 20]. The Rust equivalent enforces the same constraint but with explicit types per element, and it uses parentheses rather than square brackets.

Arrays are fixed-length collections of the same type. Both the element type and the length are part of the type signature.

let scores: [i32; 5] = [1, 2, 3, 4, 5];
let zeros = [0_i32; 8]; // 8 elements, all zero

println!("{}", scores[2]); // 3

The notation [i32; 5] reads as “array of five i32 values.” That 5 is fixed at compile time — you cannot push to a Rust array or change its length at runtime. Arrays live on the stack and are the right choice when you know the element count at compile time. When you need a growable collection, Rust has Vec<T>, which behaves more like a TypeScript array.

Check this before moving on

  • let in Rust creates an immutable binding; let mut allows reassignment
  • Shadowing creates a new binding (type can change); mut reassigns (type stays fixed)
  • [i32; 5] is an array of exactly five i32 values — the length is part of the type

Functions with explicit parameters and return types

Functions in Rust use the fn keyword. Every parameter must have an explicit type annotation — Rust doesn’t infer parameter types from usage. The return type follows the -> arrow.

fn add(a: i32, b: i32) -> i32 {
    a + b
}

fn greet(name: &str) -> String {
    format!("Hi, {}!", name)
}

Compare with TypeScript:

function add(a: number, b: number): number {
    return a + b;
}

function greet(name: string): string {
    return `Hi, ${name}!`;
}

The structure is almost identical. The differences: Rust uses -> instead of : for the return annotation, and you must annotate every parameter — there’s no inference from a default value or a prior call site.

Rust uses snake_case for function names by convention. The compiler will emit a warning if you use camelCase. That’s a quick habit to build, but it trips TypeScript developers on the first few functions.

One thing that surprised me: function definition order doesn’t matter in Rust. You can call add before it appears in the file. There’s no hoisting mechanism — the compiler makes a pass to find all definitions before type-checking the bodies.

Functions that take no parameters and return nothing use fn name() -> () — but the return type annotation is optional when the function returns the unit type (). An empty fn name() {} implicitly returns ().

Expressions versus statements: the rule that defines return values

This is the concept that most TypeScript developers need to see once before it clicks, and then it shows up everywhere.

In Rust, there is a real distinction between statements and expressions:

  • Statements perform an action and do not produce a value. Variable declarations (let y = 5;) are statements.
  • Expressions evaluate to a value. 5 + 6 is an expression that evaluates to 11. A function call is an expression. A block {} is an expression.

Functions in Rust return the value of their final expression — without a semicolon. Adding a semicolon to a line converts an expression into a statement, and statements return the unit type ().

fn square(x: i32) -> i32 {
    x * x       // no semicolon — this expression IS the return value
}

fn broken_square(x: i32) -> i32 {
    x * x;      // semicolon added — now a statement, returns ()
    // error[E0308]: mismatched types: expected `i32`, found `()`
}

The return keyword exists and works the same way it does in TypeScript, but it’s used mainly for early returns. The idiomatic Rust style is to let the last expression be the return value without return.

Blocks are also expressions:

fn describe_number(n: i32) -> &'static str {
    let category = {
        if n < 0 {
            "negative"
        } else if n == 0 {
            "zero"
        } else {
            "positive"
        }
    };
    category
}

The if expression evaluates to one of the three &str values, the block evaluates to whatever the if expression produces, and category receives that value. This pattern has no direct TypeScript equivalent — TypeScript’s if statements don’t produce values (only the ternary operator does). In Rust, if is always an expression.

The semicolon rule is the most common early mistake. If your function compiles but returns the wrong type, look at the last line first. A stray semicolon is usually the cause.

What these building blocks unlock

Variables, types, and functions are the vocabulary every other Rust feature is written in. The ownership system from Part 3 governs what happens to values as they move between functions — and now you can see those moves clearly in function signatures with explicit parameter types. Structs and enums are built from the same scalar and compound types covered here. Pattern matching destructures tuples and struct fields using the same syntax as let (x, y) = point. Error handling with match and Result returns values from expression branches inside function bodies.

The expression rule, in particular, starts to feel elegant once you use it consistently. A function body that ends with a single expression — no return, no semicolon — is a clear signal: this function exists to compute one thing, and every path produces a value of the declared type. The compiler verifies that across every branch. That’s a guarantee TypeScript can approximate with return-type annotations, but Rust enforces it structurally.

The pieces fit together tightly. Once the variable and type rules become automatic, reading Rust code becomes a matter of following the expressions.

Sources