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.
| Type | Size | Minimum Value | Maximum Value |
|---|---|---|---|
i8 | 1 byte | -128 | 127 |
i16 | 2 bytes | -32,768 | 32,767 |
i32 | 4 bytes | -2,147,483,648 | 2,147,483,647 |
i64 | 8 bytes | -9.2 × 10¹⁸ | 9.2 × 10¹⁸ |
i128 | 16 bytes | -1.7 × 10³⁸ | 1.7 × 10³⁸ |
isize | Platform | Architecture-dependent | Architecture-dependent |
u8 | 1 byte | 0 | 255 |
u16 | 2 bytes | 0 | 65,535 |
u32 | 4 bytes | 0 | 4,294,967,295 |
u64 | 8 bytes | 0 | 1.8 × 10¹⁹ |
u128 | 16 bytes | 0 | 3.4 × 10³⁸ |
usize | Platform | 0 | Architecture-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:
| Type | Size | Precision | Notes |
|---|---|---|---|
f32 | 4 bytes | ~7 decimal digits | Single precision |
f64 | 8 bytes | ~15 decimal digits | Double 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.2isn’t exactly equal to0.3in almost any programming language. Use epsilon-based comparison or a library likeordered-floatfor 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:
| Aspect | String | &str |
|---|---|---|
| Allocation | Heap (owned) | Stack / part of a String / static |
| Ownership | Owned — owns its data | Borrowed — borrows from somewhere else |
| Mutability | Can change (if mut) | Cannot change |
| Size | Dynamic, can grow | Fixed — just a view into data |
| When to use | Need modification or to return an owned string | Function 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 isf64— use smaller types only if there’s a clear memory or interoperability reason.isize/usizefor indexes and sizes — Rust’s entire indexing system usesusize; don’t usei32as an array or Vec index.- Don’t compare floats with
==— use an epsilon ((a - b).abs() < tolerance) or a dedicated library for critical precision.charis a 4-byte Unicode Scalar Value — not a single byte. Use single quotes ('a'), not double quotes.Stringvs&str—Stringis an owned, modifiable string on the heap;&stris a borrowed view into existing string data. Use&strfor 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 handleNone. Usemap,unwrap_or,if let, or?to avoid verbosematchblocks.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.
structfor data with named fields,enumfor data that can take several different forms — both can have methods throughimpl.