Data Types #

Rust is a statically typed language — every value has a type that’s definitely known at compile time, and there’s no implicit conversion between types. No number suddenly becomes a string, no integer silently widens into a float. This feels strict at first, but it’s exactly where Rust’s strength lies: entire classes of bugs caused by accidental type conversion become impossible. This article covers all categories of Rust data types — from primitive scalars on the stack to dynamic collections on the heap, from built-in types to the custom types you define yourself — along with usage patterns and pitfalls to avoid.

The Rust Data Type Map #

Before diving into each detail, it helps to understand how types in Rust are categorized:

flowchart TD
    T[Rust Data Types]

    T --> S[Scalar\nA single value]
    T --> C[Compound\nA combination of several values]
    T --> R[Reference\nA borrow to other data]
    T --> K[Collection\nDynamic data collections]
    T --> X[Special Types\nRust semantics]

    S --> S1[Integer\ni8 i16 i32 i64 i128 isize\nu8 u16 u32 u64 u128 usize]
    S --> S2[Float\nf32 f64]
    S --> S3[Boolean\nbool]
    S --> S4[Character\nchar]

    C --> C1[Tuple\nDifferent types allowed]
    C --> C2[Array\nSame type, fixed size]

    R --> R1[Immutable &T]
    R --> R2[Mutable &mut T]
    R --> R3["Slice &str &[T]"]

    K --> K1[Vec-T]
    K --> K2[String]
    K --> K3[HashMap etc.]

    X --> X1[Option-T]
    X --> X2[Result-T-E]
    X --> X3[Unit Type]

Integer Types #

Integers are the most commonly used type. Rust provides two groups: signed (can be negative) and unsigned (always zero or positive), each in six different sizes.

TypeSizeMinimum ValueMaximum Value
i81 byte-128127
i162 bytes-32,76832,767
i324 bytes-2,147,483,6482,147,483,647
i648 bytes-9.2 × 10¹⁸9.2 × 10¹⁸
i12816 bytes-1.7 × 10³⁸1.7 × 10³⁸
isizePlatformArchitecture-dependentArchitecture-dependent
u81 byte0255
u162 bytes065,535
u324 bytes04,294,967,295
u648 bytes01.8 × 10¹⁹
u12816 bytes03.4 × 10³⁸
usizePlatform0Architecture-dependent

The default type for integer literals is i32 — the most efficient size on the majority of modern architectures.

fn main() {
    // Integer literals in various bases
    let desimal = 1_000_000;      // underscores for readability
    let heksadesimal = 0xFF;      // 0x prefix
    let oktal = 0o77;             // 0o prefix
    let biner = 0b1111_0000;      // 0b prefix
    let byte = b'A';              // u8 only, ASCII value of 'A' = 65

    // Explicit type suffixes
    let kecil = 100u8;
    let besar = 9_000_000_000i64;

    println!("{} {} {} {} {}", desimal, heksadesimal, oktal, biner, byte);
}

Integer Overflow #

Rust handles integer overflow differently depending on the build profile:

fn main() {
    let maks: u8 = 255;

    // Debug build: panics at runtime with a clear message
    // Release build: wraps — 255 + 1 = 0 (like modular arithmetic)
    // let overflow = maks + 1;

    // CORRECT: use explicit methods when wrapping/saturating/checked is intended
    let wrapping = maks.wrapping_add(1);        // 0
    let saturating = maks.saturating_add(1);    // 255 (stays at the max value)
    let checked = maks.checked_add(1);          // None — impossible
    let overflowing = maks.overflowing_add(1);  // (0, true) — the value + whether it overflowed

    println!("wrapping: {}", wrapping);
    println!("saturating: {}", saturating);
    println!("checked: {:?}", checked);
    println!("overflowing: {:?}", overflowing);
}

isize and usize #

isize and usize follow the pointer size of the target platform — 32 bits on 32-bit systems, 64 bits on 64-bit systems. usize is required as the index type for arrays and collection sizes because the memory system uses the same unit.

fn main() {
    let arr = [10, 20, 30, 40, 50];

    // CORRECT: array indexes are usize
    let indeks: usize = 2;
    println!("Element {}: {}", indeks, arr[indeks]);

    // Vec::len() returns usize
    let v = vec![1, 2, 3];
    let panjang: usize = v.len();
    println!("Length: {}", panjang);

    // ANTI-PATTERN: using i32 as an index, then casting
    let i: i32 = 2;
    // println!("{}", arr[i]); // error: expected usize, found i32
    println!("{}", arr[i as usize]); // must cast explicitly — a sign of poor design
}

Float Types #

Rust has two floating-point types, both following the IEEE 754 standard:

TypeSizePrecisionNotes
f324 bytes~7 decimal digitsSingle precision
f648 bytes~15 decimal digitsDouble precision — default
fn main() {
    let x = 3.14;           // f64 — default
    let y: f32 = 3.14;      // explicit f32
    let z = 2.0f64;         // type suffix

    // Mathematical constants from the standard library
    let pi = std::f64::consts::PI;
    let e = std::f64::consts::E;
    let sqrt2 = std::f64::consts::SQRT_2;

    println!("π = {:.10}", pi);
    println!("e = {:.10}", e);
    println!("√2 = {:.10}", sqrt2);

    // Float operations
    println!("sin(π/2) = {}", (pi / 2.0).sin()); // 1.0
    println!("log₂(8) = {}", 8f64.log2());        // 3.0
    println!("2^10 = {}", 2f64.powi(10));          // 1024.0
}

Float Comparison — A Common Trap #

fn main() {
    // ANTI-PATTERN: comparing floats with == directly
    let a = 0.1 + 0.2;
    let b = 0.3;
    println!("0.1 + 0.2 == 0.3: {}", a == b); // false! due to binary representation

    // CORRECT: compare with an epsilon (error tolerance)
    let epsilon = f64::EPSILON;
    let hampir_sama = (a - b).abs() < epsilon * 10.0;
    println!("Nearly equal: {}", hampir_sama); // true

    // Special float values
    let tak_hingga = f64::INFINITY;
    let negatif_tak_hingga = f64::NEG_INFINITY;
    let bukan_angka = f64::NAN;

    println!("∞ > 1000: {}", tak_hingga > 1000.0); // true
    println!("NaN == NaN: {}", bukan_angka == bukan_angka); // false! NaN isn't equal to itself
    println!("NaN is NaN: {}", bukan_angka.is_nan()); // true — the correct way
}
Never compare float values with == directly for important business logic. Binary representation can’t represent every decimal fraction exactly — 0.1 + 0.2 isn’t exactly equal to 0.3 in almost any programming language. Use epsilon-based comparison or a library like ordered-float for cases that need precision.

Booleans #

bool has only two values: true and false. It takes 1 byte even though it only needs 1 bit — a design decision for memory alignment.

fn main() {
    let aktif: bool = true;
    let nonaktif = false;

    // Logical operations
    println!("AND: {}", aktif && nonaktif);   // false
    println!("OR:  {}", aktif || nonaktif);   // true
    println!("NOT: {}", !aktif);              // false

    // bool in conditions — no need for == true
    // ANTI-PATTERN: redundant explicit comparison
    if aktif == true {
        println!("This is redundant");
    }

    // CORRECT: just use the bool value directly
    if aktif {
        println!("More idiomatic");
    }

    // bool as an integer — can be cast but rarely needed
    let satu = true as i32;   // 1
    let nol = false as i32;   // 0
    println!("{} {}", satu, nol);

    // Functions returning bool often use the is_/has_/can_ naming convention
    let angka = -5i32;
    println!("Negative: {}", angka.is_negative());
    println!("Zero: {}", angka == 0);
}

Char #

char in Rust represents a single Unicode Scalar Value — not one byte, but one Unicode code point. It’s always 4 bytes, supporting every character from every language, symbols, and emoji.

fn main() {
    let huruf = 'A';            // ASCII, but still 4 bytes
    let aksara = 'あ';          // Japanese hiragana
    let arab = 'ع';             // Arabic letter
    let cina = '中';            // CJK character
    let emoji = '🦀';           // Ferris the crab emoji, Rust's mascot

    println!("{} {} {} {} {}", huruf, aksara, arab, cina, emoji);

    // char uses single quotes — NOT double quotes
    // ANTI-PATTERN: double quotes produce a &str, not a char
    // let salah: char = "A"; // error: expected `char`, found `&str`

    // Converting char to/from u32
    let kode = 'A' as u32;
    println!("ASCII code of 'A': {}", kode); // 65

    let dari_kode = char::from_u32(9829); // ♥
    println!("From code 9829: {:?}", dari_kode); // Some('♥')

    // Iterating a string by char — not by byte
    let kata = "halo";
    for c in kata.chars() {
        print!("[{}]", c);
    }
    println!(); // [h][a][l][o]
}

Tuples #

Tuples group a number of values that may have different types into a single unit. Their size is fixed and the type of each position is known at compile time.

fn main() {
    // Declaration with explicit type annotation
    let koordinat: (f64, f64, f64) = (1.5, -2.3, 0.0);

    // Access via index (starting at .0)
    println!("x={}, y={}, z={}", koordinat.0, koordinat.1, koordinat.2);

    // Destructuring — the more idiomatic way
    let (x, y, z) = koordinat;
    println!("Destructured: {}, {}, {}", x, y, z);

    // Partial destructuring with _
    let (penting, _, juga_penting) = (1, 2, 3);
    println!("{} {}", penting, juga_penting);

    // Tuple as a return value — returning multiple values
    fn min_maks(data: &[i32]) -> (i32, i32) {
        let min = *data.iter().min().unwrap();
        let maks = *data.iter().max().unwrap();
        (min, maks)
    }

    let angka = [5, 2, 8, 1, 9, 3];
    let (min, maks) = min_maks(&angka);
    println!("Min: {}, Max: {}", min, maks);

    // The unit type () — an empty tuple, the return type of functions without a value
    let unit: () = ();
    println!("Unit: {:?}", unit); // ()
}

Tuples are most appropriate for returning two or three values from a function where the relationship is obvious without creating a dedicated struct. For four or more values, a struct with named fields is far more readable.


Arrays #

Arrays store a number of values of the same type at a size that’s fixed since compile time. All the data lives on the stack — no heap allocation.

fn main() {
    // Declaration with explicit type and size
    let bulan: [&str; 12] = [
        "Januari", "Februari", "Maret", "April",
        "Mei", "Juni", "Juli", "Agustus",
        "September", "Oktober", "November", "Desember",
    ];

    // Initialization with the same value
    let buffer = [0u8; 1024]; // 1024 elements, all zero

    println!("3rd month: {}", bulan[2]); // Maret
    println!("Buffer size: {}", buffer.len()); // 1024

    // Iterating an array
    for (i, nama) in bulan.iter().enumerate() {
        if i < 3 {
            println!("Month {}: {}", i + 1, nama);
        }
    }

    // ANTI-PATTERN: index access without validation in production code
    let indeks: usize = 15;
    // let elemen = bulan[indeks]; // panic: index out of bounds at runtime

    // CORRECT: use .get() which returns an Option
    match bulan.get(indeks) {
        Some(nama) => println!("Month: {}", nama),
        None => println!("Index {} is invalid", indeks),
    }
}

Array vs Vec — When to Choose #

Use an Array if:
  ✓ The size is known and fixed at compile time
  ✓ The data is small and you want it on the stack (no heap allocation)
  ✓ Performance is critical and the size doesn't change
  ✓ Used as a fixed-size buffer

Use a Vec if:
  ✓ The size isn't known at compile time
  ✓ You need to add or remove elements at runtime
  ✓ Reading data from user input or files
  ✓ The result of iterator operations (.collect())

String and &str #

Rust has two main types for text, and the difference between them is one of the most important things to understand:

AspectString&str
AllocationHeap (owned)Stack / part of a String / static
OwnershipOwned — owns its dataBorrowed — borrows from somewhere else
MutabilityCan change (if mut)Cannot change
SizeDynamic, can growFixed — just a view into data
When to useNeed modification or to return an owned stringFunction parameters, string literals, slices
fn main() {
    // &str — a string literal, lives in the program's data segment ('static lifetime)
    let literal: &str = "halo dunia";

    // String — allocated on the heap, modifiable
    let mut owned = String::from("halo");
    owned.push_str(" dunia");
    owned.push('!');

    println!("{}", literal);
    println!("{}", owned);

    // Conversions
    let dari_literal: String = literal.to_string();       // &str → String
    let juga_string = String::from(literal);               // &str → String
    let sebagai_slice: &str = &owned;                      // String → &str
    let slice_sebagian: &str = &owned[0..4];               // "halo"

    println!("{} {}", dari_literal, sebagai_slice);

    // Common String operations
    let mut s = String::new();
    s.push_str("baris pertama\n");
    s.push_str("baris kedua");

    println!("Length: {} bytes", s.len());
    println!("Empty: {}", s.is_empty());
    println!("Contains 'pertama': {}", s.contains("pertama"));

    // Formatting — the most idiomatic way to create a String
    let nama = "Budi";
    let usia = 30;
    let perkenalan = format!("Name: {}, Age: {}", nama, usia);
    println!("{}", perkenalan);
}
// ANTI-PATTERN: &String parameter — too specific
fn cetak_panjang(s: &String) -> usize {
    s.len()
}

// CORRECT: &str parameter — more flexible, accepts both &String and &str
fn cetak_panjang(s: &str) -> usize {
    s.len()
}

fn main() {
    let owned = String::from("halo");
    let literal = "dunia";

    println!("{}", cetak_panjang(&owned));   // &String → &str automatically
    println!("{}", cetak_panjang(literal));  // &str directly
    println!("{}", cetak_panjang(&owned[1..3])); // slices are also valid
}

Vec<T> #

Vec<T> is a dynamic array — like an array, but its size can change at runtime. It’s the most commonly used collection in Rust.

fn main() {
    // Creating a Vec
    let mut v1: Vec<i32> = Vec::new();  // empty
    let v2 = vec![1, 2, 3, 4, 5];       // the vec! macro — the most concise way
    let v3: Vec<i32> = (1..=10).collect(); // from an iterator

    // Adding elements
    v1.push(10);
    v1.push(20);
    v1.push(30);

    // Accessing elements
    println!("First element: {}", v2[0]);       // panics if out of bounds
    println!("Safe access: {:?}", v2.get(10));   // None — no panic

    // Modifying elements
    let mut v4 = vec![1, 2, 3];
    v4[1] = 99;
    println!("{:?}", v4); // [1, 99, 3]

    // Removing elements
    let terakhir = v4.pop();         // remove and return the last element
    let dua = v4.remove(0);          // remove at an index, shifting other elements
    println!("Pop: {:?}, Remove: {}", terakhir, dua);

    // Iterating
    for elemen in &v2 {              // immutable borrow
        print!("{} ", elemen);
    }
    println!();

    for elemen in &mut v4 {          // mutable borrow — can modify
        *elemen *= 2;
    }
    println!("{:?}", v4);

    // Capacity and length
    let mut v5: Vec<i32> = Vec::with_capacity(100); // allocate for 100 elements
    println!("Length: {}, Capacity: {}", v5.len(), v5.capacity());
}

Option<T> #

Option<T> is Rust’s built-in enum representing a value that may exist (Some(T)) or not (None). It’s the safe replacement for null — the compiler forces you to handle both possibilities.

fn cari_pengguna(id: u32) -> Option<String> {
    match id {
        1 => Some(String::from("Budi")),
        2 => Some(String::from("Sari")),
        _ => None,
    }
}

fn main() {
    // Pattern matching — the most explicit way
    match cari_pengguna(1) {
        Some(nama) => println!("Found: {}", nama),
        None => println!("Not found"),
    }

    // if let — more concise when you only need the Some case
    if let Some(nama) = cari_pengguna(2) {
        println!("User: {}", nama);
    }

    // unwrap_or — a default value if None
    let nama = cari_pengguna(99).unwrap_or(String::from("Anonim"));
    println!("Name: {}", nama);

    // unwrap_or_else — a default value from a closure (lazy evaluation)
    let nama2 = cari_pengguna(99).unwrap_or_else(|| format!("Guest-{}", 99));
    println!("Name2: {}", nama2);

    // map — transform the value inside Some, None stays None
    let panjang = cari_pengguna(1).map(|n| n.len());
    println!("Name length: {:?}", panjang); // Some(4)

    // ANTI-PATTERN: unwrap without checking in production code
    // cari_pengguna(99).unwrap(); // panic: called `Option::unwrap()` on a `None` value

    // The ? operator — propagates None upward (in functions returning Option)
    fn nama_uppercase(id: u32) -> Option<String> {
        let nama = cari_pengguna(id)?; // if None, return None immediately
        Some(nama.to_uppercase())
    }
    println!("{:?}", nama_uppercase(1)); // Some("BUDI")
    println!("{:?}", nama_uppercase(99)); // None
}

Result<T, E> #

Result<T, E> is the enum for operations that can succeed (Ok(T)) or fail (Err(E)). It’s Rust’s idiomatic way of handling recoverable errors.

use std::num::ParseIntError;

fn parse_positif(s: &str) -> Result<u32, ParseIntError> {
    s.trim().parse::<u32>()
}

fn main() {
    // Pattern matching
    match parse_positif("42") {
        Ok(n) => println!("Success: {}", n),
        Err(e) => println!("Failed: {}", e),
    }

    // unwrap_or — a default value on error
    let n = parse_positif("abc").unwrap_or(0);
    println!("Default: {}", n);

    // map and map_err — transform Ok or Err
    let dikali_dua = parse_positif("21").map(|n| n * 2);
    println!("{:?}", dikali_dua); // Ok(42)

    // is_ok() and is_err()
    println!("Valid: {}", parse_positif("5").is_ok());     // true
    println!("Invalid: {}", parse_positif("x").is_err()); // true

    // The ? operator in a function returning Result
    fn hitung(a: &str, b: &str) -> Result<u32, ParseIntError> {
        let x = parse_positif(a)?; // if Err, return Err straight to the caller
        let y = parse_positif(b)?;
        Ok(x + y)
    }

    println!("{:?}", hitung("10", "32")); // Ok(42)
    println!("{:?}", hitung("10", "xx")); // Err(...)
}

Generic Types #

Generics let you write functions, structs, and enums that work for many types without code duplication. The compiler generates a specific version for every type used — monomorphization — so there’s no runtime overhead.

// Generic function with a trait bound
fn terbesar<T: PartialOrd>(daftar: &[T]) -> &T {
    let mut maks = &daftar[0];
    for item in daftar {
        if item > maks {
            maks = item;
        }
    }
    maks
}

// Generic struct
struct Pasangan<T, U> {
    pertama: T,
    kedua: U,
}

impl<T: std::fmt::Display, U: std::fmt::Display> Pasangan<T, U> {
    fn cetak(&self) {
        println!("({}, {})", self.pertama, self.kedua);
    }
}

fn main() {
    // Generic functions work for both i32 and f64
    let angka = vec![34, 50, 25, 100, 65];
    println!("Largest: {}", terbesar(&angka));

    let huruf = vec!['y', 'm', 'a', 'q'];
    println!("Largest: {}", terbesar(&huruf));

    // Generic structs with different types
    let p1 = Pasangan { pertama: 5, kedua: "halo" };
    let p2 = Pasangan { pertama: 3.14, kedua: true };
    p1.cetak(); // (5, halo)
    p2.cetak(); // (3.14, true)
}

Structs and Enums as Custom Types #

For more complex data, Rust provides struct and enum for defining custom types that are meaningful in your problem domain.

// Struct with named fields
struct Pengguna {
    nama: String,
    email: String,
    usia: u8,
    aktif: bool,
}

impl Pengguna {
    fn baru(nama: &str, email: &str, usia: u8) -> Self {
        Pengguna {
            nama: nama.to_string(),
            email: email.to_string(),
            usia,
            aktif: true,
        }
    }

    fn sapa(&self) -> String {
        format!("Hello, {}!", self.nama)
    }
}

// Enum with data in every variant
enum Bentuk {
    Lingkaran(f64),                       // radius
    Persegi(f64),                         // side
    PersegPanjang { lebar: f64, tinggi: f64 }, // named fields
}

impl Bentuk {
    fn luas(&self) -> f64 {
        match self {
            Bentuk::Lingkaran(r) => std::f64::consts::PI * r * r,
            Bentuk::Persegi(s) => s * s,
            Bentuk::PersegPanjang { lebar, tinggi } => lebar * tinggi,
        }
    }
}

fn main() {
    let user = Pengguna::baru("Budi", "[email protected]", 28);
    println!("{}", user.sapa());
    println!("Active: {}", user.aktif);

    let bentuk_list = vec![
        Bentuk::Lingkaran(5.0),
        Bentuk::Persegi(4.0),
        Bentuk::PersegPanjang { lebar: 6.0, tinggi: 3.0 },
    ];

    for bentuk in &bentuk_list {
        println!("Area: {:.2}", bentuk.luas());
    }
}

Summary #

  • The default integer is i32, the default float is f64 — use smaller types only if there’s a clear memory or interoperability reason.
  • isize/usize for indexes and sizes — Rust’s entire indexing system uses usize; don’t use i32 as an array or Vec index.
  • Don’t compare floats with == — use an epsilon ((a - b).abs() < tolerance) or a dedicated library for critical precision.
  • char is a 4-byte Unicode Scalar Value — not a single byte. Use single quotes ('a'), not double quotes.
  • String vs &strString is an owned, modifiable string on the heap; &str is a borrowed view into existing string data. Use &str for function parameters.
  • Arrays for fixed sizes on the stack, Vec<T> for dynamic sizes on the heap — both can be iterated and sliced the same way.
  • Option<T> replaces null — the compiler forces you to handle None. Use map, unwrap_or, if let, or ? to avoid verbose match blocks.
  • Result<T, E> for recoverable errors — the ? operator dramatically simplifies error propagation.
  • Generics with no runtime overhead — Rust uses monomorphization: the compiler generates type-specific code, with the same performance as non-generic code.
  • struct for data with named fields, enum for data that can take several different forms — both can have methods through impl.

← Previous: Constants   Next: Operators →

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