Core Syntax #

Rust was designed on the principle that memory safety and high performance don’t have to be traded off against each other. As a result, Rust’s syntax feels different from languages like Python, Java, or Go — there are concepts like ownership, borrowing, and lifetimes that have no equivalent elsewhere. This article gives a comprehensive overview of the Rust syntax elements you’ll encounter every day: from declaring variables that are immutable by default, to how match replaces switch in a far more expressive way. Understanding these foundations is a prerequisite before diving into the deeper topics covered in later articles.

Anatomy of a Rust Program #

Every runnable Rust program has a single entry point: the main function. Understanding its structure from the start helps you read other people’s Rust code faster.

// This is the simplest runnable Rust program

fn main() {
    // println! is a macro, not an ordinary function
    // The exclamation mark (!) distinguishes a macro from a function call
    println!("Hello, Rust!");

    // Variables are declared with let
    let angka = 42;
    let pesan = "The value is";

    // Format strings use {} as placeholders
    println!("{}: {}", pesan, angka);
}

A few things stand out immediately compared to other languages:

  • The ! after a function name means it’s a macro, not an ordinary function call. println!, vec!, format! are all macros.
  • Every statement ends with a semicolon (;), except expressions that are return values.
  • Rust is a statically typed language — the type of every variable is known at compile time, although the compiler can often infer it automatically.

Comments #

Rust supports three comment styles, each with a different purpose. The difference between ordinary comments and documentation comments isn’t just style — documentation comments are actually processed by the rustdoc tool to generate HTML documentation.

// Single-line comment — ignored by the compiler

/*
   Block comment — can span
   multiple lines at once
*/

/// Documentation comment for the item below it (function, struct, etc.)
/// Supports Markdown syntax and is processed by rustdoc.
///
/// # Examples
///
/// ```
/// let hasil = tambah(2, 3);
/// assert_eq!(hasil, 5);
/// ```
fn tambah(a: i32, b: i32) -> i32 {
    a + b
}

//! Documentation comment for the module or crate itself
//! Usually lives on the first line of a lib.rs or main.rs file

The /// comment has a unique feature: code blocks inside it (between triple backticks) can be run as doc tests by cargo test. This keeps code examples in the documentation in sync with the actual implementation.


Variables and Mutability #

Variables in Rust are immutable by default. This isn’t just a convention — the compiler will refuse to compile if you try to change the value of a variable that wasn’t declared mut. This design decision pushes you to be explicit about which data needs to change and which doesn’t.

fn main() {
    // ANTI-PATTERN: trying to change an immutable variable
    let x = 5;
    x = 6; // error[E0384]: cannot assign twice to immutable variable `x`

    // CORRECT: declare mut if you really need to change it
    let mut y = 5;
    y = 6; // ✓ valid
    println!("y = {}", y);
}

Shadowing #

Rust has the concept of shadowing — you can declare a new variable with the same name using let again. This is different from mutation: the old variable is replaced by a new one, which can even have a different type.

fn main() {
    let angka = 5;

    // Shadowing: create a new variable with the same name
    let angka = angka + 1;       // angka = 6

    // Shadowing can even change the type
    let angka = angka.to_string(); // now angka is a String, not an i32

    println!("angka = {}", angka); // angka = "6"

    // ANTI-PATTERN: using mut to "change the type"
    // let mut nilai = 5;
    // nilai = "lima"; // error — you can't change the type of a mut variable
}

Constants #

Constants are declared with const, not let. Unlike immutable variables, constants must always have an explicit type annotation, their value must be known at compile time, and they’re valid across the entire scope.

// Constants use SCREAMING_SNAKE_CASE
const KECEPATAN_CAHAYA: u64 = 299_792_458; // m/s

// Underscores can be used as digit separators for readability
const POPULASI_BUMI: u64 = 8_000_000_000;

fn main() {
    println!("Speed of light: {} m/s", KECEPATAN_CAHAYA);
}

Data Types #

Rust has a rich type system. The compiler infers types automatically in most cases, but understanding the basic types matters for writing correct and efficient code.

Scalar Types #

Scalar types represent a single value. There are four main categories:

CategoryTypeSizeRange / Notes
Signed integeri81 byte-128 to 127
i162 bytes-32,768 to 32,767
i324 bytesDefault for integers
i648 bytesOver 9 quadrillion
i12816 bytesVery large
isizePlatform32 or 64 bits
Unsigned integeru81 byte0 to 255
u162 bytes0 to 65,535
u324 bytes0 to ~4.3 billion
u648 bytes0 to ~18.4 quintillion
u12816 bytesVery large
usizePlatformUsed for indexing
Floatf324 bytesSingle precision
f648 bytesDouble precision (default)
Booleanbool1 bytetrue or false
Characterchar4 bytesUnicode scalar value
fn main() {
    // Integers — use a suffix for an explicit type
    let a: i32 = -42;
    let b = 1_000_000u64;    // u64 suffix, underscore for readability
    let c = 0xFF;            // hexadecimal
    let d = 0b1111_0000;     // binary
    let e = 0o77;            // octal

    // Floats
    let f: f64 = 3.14159;
    let g = 2.0f32;          // f32 suffix

    // Booleans
    let benar: bool = true;
    let salah = false;

    // Char — use single quotes, not double quotes
    let huruf = 'A';
    let emoji = '🦀'; // Rust char supports all of Unicode
    let karakter_cina = '中';

    println!("{} {} {} {} {}", a, f, benar, huruf, emoji);
}

Compound Types #

Compound types group several values into one.

fn main() {
    // Tuple: a collection of values that can have different types
    let koordinat: (f64, f64, f64) = (1.0, 2.5, -0.3);

    // Access via index
    let x = koordinat.0;
    let y = koordinat.1;

    // Destructuring — the more idiomatic way
    let (px, py, pz) = koordinat;
    println!("Position: {}, {}, {}", px, py, pz);

    // Array: a collection of values with the SAME type and FIXED length
    let angka: [i32; 5] = [1, 2, 3, 4, 5];
    let semua_nol = [0; 10]; // 10 elements, all zero

    // Access via index
    println!("First element: {}", angka[0]);
    println!("Array length: {}", angka.len());

    // ANTI-PATTERN: accessing an index out of bounds
    // let x = angka[10]; // runtime panic: index out of bounds
}
Arrays in Rust differ from Vec<T>. Arrays have a fixed size and are allocated on the stack, while Vec<T> is dynamically sized and allocated on the heap. For collections whose size isn’t known at compile time, use Vec<T>.

Control Flow #

If and If-Let #

if in Rust is an expression, not a statement. That means it can return a value and be used on the right-hand side of an assignment.

fn main() {
    let suhu = 28;

    // if as an ordinary statement
    if suhu > 30 {
        println!("Hot");
    } else if suhu > 20 {
        println!("Comfortable");
    } else {
        println!("Cold");
    }

    // CORRECT: if as an expression — returns a value
    let kondisi = if suhu > 30 { "panas" } else { "sejuk" };
    println!("Weather: {}", kondisi);

    // ANTI-PATTERN: every branch must have the same type
    // let kondisi = if suhu > 30 { "panas" } else { 0 };
    // error: `if` and `else` have incompatible types
}

Match #

match is one of the most powerful features in Rust. It forces you to handle every possibility — the compiler errors if any case isn’t handled.

fn main() {
    let angka = 7;

    // ANTI-PATTERN: chained if-else for pattern matching
    // if angka == 1 { ... } else if angka == 2 { ... } — verbose and not exhaustive

    // CORRECT: use match
    match angka {
        1 => println!("One"),
        2 | 3 => println!("Two or Three"),      // multiple patterns
        4..=6 => println!("Four to Six"),   // range pattern
        n if n > 6 => println!("Greater than 6: {}", n), // guard condition
        _ => println!("Something else"),                  // wildcard — required if not exhaustive
    }

    // match can also return a value
    let deskripsi = match angka {
        1..=3 => "kecil",
        4..=6 => "sedang",
        _ => "besar",
    };
    println!("Number {}: {}", angka, deskripsi);
}

Loop, While, and For #

Rust has three looping constructs, each for a different case.

fn main() {
    // loop — an unbounded loop, stopped with break
    // Can return a value via break
    let mut counter = 0;
    let hasil = loop {
        counter += 1;
        if counter == 10 {
            break counter * 2; // return a value from the loop
        }
    };
    println!("Loop result: {}", hasil); // 20

    // while — loops while the condition is true
    let mut n = 0;
    while n < 5 {
        print!("{} ", n);
        n += 1;
    }
    println!();

    // for — the most idiomatic way to iterate over collections
    let koleksi = [10, 20, 30, 40, 50];
    for elemen in koleksi {
        print!("{} ", elemen);
    }
    println!();

    // Ranges: 0..5 (exclusive), 0..=5 (inclusive)
    for i in 0..5 {
        print!("{} ", i); // 0 1 2 3 4
    }
    println!();

    // Enumerate to get the index and value at the same time
    for (indeks, nilai) in koleksi.iter().enumerate() {
        println!("[{}] = {}", indeks, nilai);
    }
}

Functions #

Functions in Rust are declared with fn. Parameters always require explicit type annotations — there’s no type inference for function parameters.

// Function without a return value (return type () — the unit type)
fn sapa(nama: &str) {
    println!("Hello, {}!", nama);
}

// Function with a return value — the type after ->
fn tambah(a: i32, b: i32) -> i32 {
    // CORRECT: an expression without a semicolon is the implicit return value
    a + b
}

// ANTI-PATTERN: using explicit return for a case that isn't an early return
fn kurang(a: i32, b: i32) -> i32 {
    return a - b; // redundant for a simple case
}

// CORRECT: explicit return is only for early returns
fn pembagian_aman(a: i32, b: i32) -> Option<i32> {
    if b == 0 {
        return None; // early return — explicit return makes sense here
    }
    Some(a / b) // implicit return at the end of the function
}

fn main() {
    sapa("Rustacean");
    println!("3 + 4 = {}", tambah(3, 4));

    match pembagian_aman(10, 0) {
        Some(hasil) => println!("Result: {}", hasil),
        None => println!("Cannot divide by zero"),
    }
}

Closures #

Closures are anonymous functions that can capture variables from their surrounding scope.

fn main() {
    let pengali = 3;

    // The closure captures `pengali` from the outer scope
    let kalikan = |x: i32| x * pengali;

    println!("5 x {} = {}", pengali, kalikan(5)); // 15

    // Closures are often used with iterator methods
    let angka = vec![1, 2, 3, 4, 5];

    let genap: Vec<i32> = angka.iter()
        .filter(|&&x| x % 2 == 0)
        .copied()
        .collect();

    let dikuadratkan: Vec<i32> = angka.iter()
        .map(|&x| x * x)
        .collect();

    println!("Even: {:?}", genap);           // [2, 4]
    println!("Squares: {:?}", dikuadratkan);  // [1, 4, 9, 16, 25]
}

Structs #

Structs are Rust’s way of creating custom data types with named fields. They’re the equivalent of classes in other languages, but without inheritance.

// Struct definition
struct Persegi {
    lebar: u32,
    tinggi: u32,
}

// Implementing methods for the struct with an impl block
impl Persegi {
    // Associated function (not a method) — no self parameter
    // Usually used as a constructor
    fn baru(lebar: u32, tinggi: u32) -> Persegi {
        Persegi { lebar, tinggi } // field shorthand when names match the variables
    }

    // Method — the first parameter is always &self, &mut self, or self
    fn luas(&self) -> u32 {
        self.lebar * self.tinggi
    }

    fn keliling(&self) -> u32 {
        2 * (self.lebar + self.tinggi)
    }

    fn perbesar(&mut self, faktor: u32) {
        self.lebar *= faktor;
        self.tinggi *= faktor;
    }
}

fn main() {
    let mut p = Persegi::baru(10, 5);
    println!("Area: {}", p.luas());       // 50
    println!("Perimeter: {}", p.keliling()); // 30

    p.perbesar(2);
    println!("After scaling — Area: {}", p.luas()); // 200

    // Struct update syntax — copy fields from another struct
    let p2 = Persegi { lebar: 20, ..p };
    println!("p2 — Width: {}, Height: {}", p2.lebar, p2.tinggi); // 20, 10
}

Enums #

Enums in Rust are far more expressive than enums in other languages — every variant can carry data of a different type.

// Enum with various kinds of variants
enum Pesan {
    Keluar,                          // variant without data
    Pindah { x: i32, y: i32 },      // variant with named fields
    Tulis(String),                   // variant with a single value
    GantiWarna(u8, u8, u8),         // variant with multiple values
}

impl Pesan {
    fn proses(&self) {
        match self {
            Pesan::Keluar => println!("Program exiting"),
            Pesan::Pindah { x, y } => println!("Move to ({}, {})", x, y),
            Pesan::Tulis(teks) => println!("Writing: {}", teks),
            Pesan::GantiWarna(r, g, b) => println!("Color: #{:02X}{:02X}{:02X}", r, g, b),
        }
    }
}

fn main() {
    let pesan_list = vec![
        Pesan::Pindah { x: 10, y: 20 },
        Pesan::Tulis(String::from("Hello")),
        Pesan::GantiWarna(255, 128, 0),
        Pesan::Keluar,
    ];

    for pesan in &pesan_list {
        pesan.proses();
    }
}

Option and Result — The Two Most Important Enums #

Option<T> and Result<T, E> are Rust’s built-in enums that form the foundation of error handling and optional values. Rust has no nullOption<T> is its safe replacement.

// Option<T> — a value that may or may not exist
// enum Option<T> {
//     Some(T),
//     None,
// }

fn cari_elemen(koleksi: &[i32], target: i32) -> Option<usize> {
    for (i, &val) in koleksi.iter().enumerate() {
        if val == target {
            return Some(i);
        }
    }
    None
}

// Result<T, E> — an operation that can succeed or fail
// enum Result<T, E> {
//     Ok(T),
//     Err(E),
// }

fn bagi(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        Err(String::from("Cannot divide by zero"))
    } else {
        Ok(a / b)
    }
}

fn main() {
    let data = [3, 7, 1, 9, 4];

    // Handling Option
    match cari_elemen(&data, 9) {
        Some(indeks) => println!("Found at index {}", indeks),
        None => println!("Not found"),
    }

    // The concise way with if let
    if let Some(idx) = cari_elemen(&data, 7) {
        println!("7 is at index {}", idx);
    }

    // Handling Result
    match bagi(10.0, 3.0) {
        Ok(hasil) => println!("Result: {:.4}", hasil),
        Err(e) => println!("Error: {}", e),
    }

    // The ? operator — automatic error propagation (inside functions that return Result)
    // Covered in more detail in the Error Handling article
}

Traits #

Traits define behavior that can be shared across types. The concept is similar to interfaces in Java or Go, but more flexible — you can implement a trait for types you create as well as types that already exist in other libraries.

// Trait definition
trait Deskripsi {
    // Method that must be implemented
    fn deskripsikan(&self) -> String;

    // Method with a default implementation — can be overridden
    fn cetak(&self) {
        println!("{}", self.deskripsikan());
    }
}

struct Pengguna {
    nama: String,
    email: String,
}

struct Produk {
    nama: String,
    harga: f64,
}

// Implementing the trait for Pengguna
impl Deskripsi for Pengguna {
    fn deskripsikan(&self) -> String {
        format!("User: {} ({})", self.nama, self.email)
    }
}

// Implementing the trait for Produk
impl Deskripsi for Produk {
    fn deskripsikan(&self) -> String {
        format!("Product: {} — Rp{:.0}", self.nama, self.harga)
    }

    // Override the default implementation
    fn cetak(&self) {
        println!("=== {} ===", self.deskripsikan());
    }
}

// Trait as a function parameter — generic with a trait bound
fn tampilkan<T: Deskripsi>(item: &T) {
    item.cetak();
}

fn main() {
    let user = Pengguna {
        nama: String::from("Budi"),
        email: String::from("[email protected]"),
    };

    let produk = Produk {
        nama: String::from("Laptop"),
        harga: 15_000_000.0,
    };

    tampilkan(&user);
    tampilkan(&produk);
}

Ownership, Borrowing, and Lifetimes #

These are the most unique concepts in Rust — they don’t exist in any other commonly used language. Ownership is the mechanism that lets Rust guarantee memory safety without a garbage collector.

flowchart TD
    A[Value created] --> B{Who owns it?}
    B --> C[One owner at a time]
    C --> D{What happens next?}
    D --> E["Move — ownership transfers\nthe old owner can no longer use it"]
    D --> F["Borrow &T — read-only borrow\nmany can coexist"]
    D --> G["Borrow &mut T — read-write borrow\nonly one at a time"]
    D --> H["Clone — make a copy\nthe old owner stays valid"]
    E --> I[The new owner is responsible for the drop]
    F --> I
    G --> I
    H --> I
    I --> J[The value is dropped automatically when the owner leaves scope]

Ownership #

fn main() {
    // A String is allocated on the heap — ownership can be transferred
    let s1 = String::from("halo");
    let s2 = s1; // MOVE: s1 can no longer be used

    // ANTI-PATTERN: using a variable after it's been moved
    // println!("{}", s1); // error[E0382]: borrow of moved value: `s1`

    // CORRECT: use s2, or clone s1 before the move
    println!("{}", s2);

    let s3 = String::from("dunia");
    let s4 = s3.clone(); // clone: make a copy, both are valid
    println!("{} and {}", s3, s4);

    // Copy types (integers, floats, bool, char) are not moved — always copied
    let x = 5;
    let y = x;           // copy, not move
    println!("{} {}", x, y); // both are valid
}

Borrowing #

Borrowing lets you use a value without taking ownership of it.

fn hitung_panjang(s: &String) -> usize { // &String = borrow, not take ownership
    s.len()
} // s leaves scope, but isn't dropped because it was only borrowed

fn tambahkan_kata(s: &mut String) { // &mut = mutable borrow
    s.push_str(", dunia");
}

fn main() {
    let s = String::from("halo");

    // Immutable borrow — s is still valid after the call
    let panjang = hitung_panjang(&s);
    println!("Length of '{}' is {}", s, panjang);

    let mut s2 = String::from("halo");
    tambahkan_kata(&mut s2);
    println!("{}", s2); // "halo, dunia"

    // ANTI-PATTERN: more than one mutable borrow at the same time
    let mut s3 = String::from("test");
    let r1 = &mut s3;
    // let r2 = &mut s3; // error: cannot borrow s3 as mutable more than once
    println!("{}", r1);
}

Lifetimes #

Lifetimes are how Rust ensures references are always valid while they’re in use. In many cases the compiler can infer lifetimes automatically, but for functions that return a reference derived from several parameters, you need an explicit annotation.

// Without a lifetime annotation, the compiler can't know
// whether the return value refers to x or y
// The 'a annotation means: the return value has a lifetime at least as long as x and y
fn terpanjang<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

fn main() {
    let string1 = String::from("a long string");
    let hasil;
    {
        let string2 = String::from("xyz");
        hasil = terpanjang(string1.as_str(), string2.as_str());
        println!("Longest string: {}", hasil); // ✓ valid here
    }
    // println!("{}", hasil); // error — string2 is no longer valid
}

Modules and Visibility #

Rust uses a module system to organize code. All items (functions, structs, enums, etc.) are private by default — they must be explicitly marked pub to be accessible from outside the module.

// Modules can be defined inline or in separate files
mod geometri {
    // Private by default — only accessible inside this module
    fn luas_internal(lebar: f64, tinggi: f64) -> f64 {
        lebar * tinggi
    }

    // pub — accessible from outside the module
    pub struct Persegi {
        pub lebar: f64,   // pub field — accessible from outside
        tinggi: f64,      // private field — not accessible from outside
    }

    impl Persegi {
        pub fn baru(lebar: f64, tinggi: f64) -> Self {
            Persegi { lebar, tinggi }
        }

        pub fn luas(&self) -> f64 {
            luas_internal(self.lebar, self.tinggi) // allowed to access private functions in the same module
        }
    }

    pub mod lingkaran {
        pub fn luas(radius: f64) -> f64 {
            std::f64::consts::PI * radius * radius
        }
    }
}

fn main() {
    // use to shorten paths
    use geometri::Persegi;
    use geometri::lingkaran;

    let p = Persegi::baru(10.0, 5.0);
    println!("Rectangle area: {}", p.luas());
    println!("Width: {}", p.lebar); // ✓ pub field
    // println!("{}", p.tinggi);    // ✗ error: private field

    println!("Circle area r=7: {:.2}", lingkaran::luas(7.0));
}

Summary #

  • Immutable by default — variables in Rust can’t be changed unless declared with mut. This isn’t a limitation but a design that pushes you toward safer code.
  • Shadowing ≠ mutationlet x = x + 1 creates a new variable, not changes the old one. It can even change the type at the same time.
  • match enforces exhaustiveness — every possibility must be handled, and the compiler errors if any is missed. Use _ as a wildcard for other cases.
  • Expression-orientedif, match, and code blocks {} can return values. An expression without a semicolon at the end of a function is the implicit return value.
  • Option<T> replaces null — there’s no null in Rust. A value that may not exist is represented as Some(value) or None.
  • Result<T, E> for errors — operations that can fail return a Result. The ? operator dramatically shortens error propagation.
  • Ownership has three rules — every value has exactly one owner; the owner can be moved, borrowed (&T), or mutably borrowed (&mut T); the value is dropped automatically when the owner leaves scope.
  • Everything is private by default — functions, structs, and fields must be explicitly marked pub to be accessible from outside the module.
  • Traits are behavioral contracts — they can be implemented for any type, even types from other libraries, as long as you define the trait.

← Previous: Installation   Next: Comments →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact