3 min read
On this page

Primitive Types & Enums

Rust's type system is expressive, precise, and designed to catch bugs at compile time. Primitives are explicit about their sizes. Enums are more powerful than in any other mainstream language. Option<T> replaces null. Result<T, E> replaces exceptions. Once you understand these types, you understand how Rust models data.

Integer Types

Rust integers have explicit sizes. No guessing whether int is 32 or 64 bits.

fn main() {
    let a: i8 = -128;        // signed 8-bit: -128 to 127
    let b: u8 = 255;         // unsigned 8-bit: 0 to 255
    let c: i32 = -2_000_000; // signed 32-bit (default integer type)
    let d: u64 = 18_000_000_000_000;
    let e: isize = -1;       // pointer-sized signed (64-bit on 64-bit systems)
    let f: usize = 42;       // pointer-sized unsigned (used for indexing)

    println!("{} {} {} {} {} {}", a, b, c, d, e, f);
}
-128 255 -2000000 18000000000000 -1 42

i32 is the default when you write let x = 42. Use usize for collection indices and sizes. Use specific widths when you care about memory layout or protocol compatibility.

Underscores in numeric literals (2_000_000) are visual separators. Use them freely for readability.

Floating Point, Bool & Char

fn main() {
    let pi: f64 = 3.14159265358979;  // 64-bit float (default)
    let approx: f32 = 3.14;          // 32-bit float

    let active: bool = true;
    let emoji: char = '🦀';          // char is 4 bytes (Unicode scalar value)

    println!("{:.4} {} {} {}", pi, approx, active, emoji);
}
3.1416 3.14 true 🦀

f64 is the default float type. char is not a byte — it is a Unicode scalar value and always 4 bytes wide.

str vs String

This trips up every Rust beginner. There are two string types and you need both:

fn main() {
    // &str: a borrowed string slice. Fixed content, no allocation.
    let greeting: &str = "hello"; // string literal, stored in the binary

    // String: an owned, heap-allocated, growable string.
    let mut name = String::from("world");
    name.push('!');

    // Converting between them:
    let owned: String = greeting.to_string();  // &str -> String
    let borrowed: &str = &name;                // String -> &str (auto-deref)

    println!("{}, {}", greeting, name);
    println!("{}, {}", owned, borrowed);
}
hello, world!
hello, world!

Rule of thumb: use &str for function parameters (accepts both types). Use String when you need to own or modify the string.

Enums: More Powerful Than You Think

Rust enums are algebraic data types. Each variant can hold different data. This is not a list of integer constants — it is a way to model distinct states with associated data.

enum IpAddress {
    V4(u8, u8, u8, u8),
    V6(String),
}

enum Command {
    Quit,
    Echo(String),
    Move { x: i32, y: i32 },
    Color(u8, u8, u8),
}

fn handle_command(cmd: Command) {
    match cmd {
        Command::Quit => println!("Quitting"),
        Command::Echo(msg) => println!("Echo: {}", msg),
        Command::Move { x, y } => println!("Moving to ({}, {})", x, y),
        Command::Color(r, g, b) => println!("Color: #{:02x}{:02x}{:02x}", r, g, b),
    }
}

fn main() {
    let addr = IpAddress::V4(127, 0, 0, 1);
    match addr {
        IpAddress::V4(a, b, c, d) => println!("{}.{}.{}.{}", a, b, c, d),
        IpAddress::V6(s) => println!("{}", s),
    }

    handle_command(Command::Move { x: 10, y: 20 });
    handle_command(Command::Color(255, 128, 0));
}
127.0.0.1
Moving to (10, 20)
Color: #ff8000

Each variant is a distinct type that can carry its own data. match destructures them exhaustively — the compiler ensures you handle every case.

Option: No More Null

Rust has no null. Instead, the standard library provides Option<T>:

fn find_user(id: u64) -> Option<String> {
    match id {
        1 => Some(String::from("Alice")),
        2 => Some(String::from("Bob")),
        _ => None,
    }
}

fn main() {
    // You must handle both cases. The compiler enforces this.
    let user = find_user(1);
    match user {
        Some(name) => println!("Found: {}", name),
        None => println!("Not found"),
    }

    // Shorter alternatives:
    if let Some(name) = find_user(2) {
        println!("Found: {}", name);
    }

    // unwrap_or provides a default:
    let name = find_user(99).unwrap_or(String::from("anonymous"));
    println!("User: {}", name);

    // map transforms the inner value:
    let upper = find_user(1).map(|n| n.to_uppercase());
    println!("{:?}", upper);
}
Found: Alice
Found: Bob
User: anonymous
Some("ALICE")

Every Option must be explicitly handled before you can access the value. This eliminates null pointer exceptions entirely.

Result<T, E>: No More Exceptions

Rust has no exceptions. Operations that can fail return Result<T, E>:

use std::fs;
use std::num::ParseIntError;

fn parse_port(s: &str) -> Result<u16, ParseIntError> {
    s.parse::<u16>()
}

fn main() {
    // Handling Result explicitly:
    match parse_port("8080") {
        Ok(port) => println!("Port: {}", port),
        Err(e) => println!("Invalid port: {}", e),
    }

    match parse_port("not_a_number") {
        Ok(port) => println!("Port: {}", port),
        Err(e) => println!("Invalid port: {}", e),
    }

    // Reading a file returns Result:
    match fs::read_to_string("/etc/hostname") {
        Ok(contents) => println!("Hostname: {}", contents.trim()),
        Err(e) => println!("Could not read hostname: {}", e),
    }
}
Port: 8080
Invalid port: invalid digit found in string
Could not read hostname: No such file or directory (os error 2)

Result makes error handling visible in the type signature. You cannot ignore errors — the compiler forces you to deal with them.

Pattern Matching with match

match is exhaustive, type-safe, and the primary way to work with enums:

enum TrafficLight {
    Red,
    Yellow,
    Green,
}

fn action(light: &TrafficLight) -> &str {
    match light {
        TrafficLight::Red => "stop",
        TrafficLight::Yellow => "caution",
        TrafficLight::Green => "go",
    }
}

fn describe_number(n: i32) -> String {
    match n {
        0 => String::from("zero"),
        1..=9 => String::from("single digit"),
        10 | 20 | 30 => String::from("round tens"),
        n if n < 0 => format!("negative: {}", n),
        _ => format!("other: {}", n),
    }
}

fn main() {
    let light = TrafficLight::Green;
    println!("{}", action(&light));

    for n in [-5, 0, 3, 10, 42] {
        println!("{}: {}", n, describe_number(n));
    }
}
go
-5: negative: -5
0: zero
3: single digit
10: round tens
42: other: 42

If you forget a variant, the compiler tells you. If you add a new variant to an enum, every match that handles it must be updated. This is a feature — it prevents the "I added a case but forgot to handle it" class of bugs.

Combining Option & Result

Real-world code chains these types together:

fn parse_header_value(headers: &str, key: &str) -> Option<u64> {
    headers
        .lines()
        .find(|line| line.starts_with(key))     // Option<&str>
        .and_then(|line| line.split(':').nth(1)) // Option<&str>
        .and_then(|val| val.trim().parse().ok()) // Option<u64>
}

fn main() {
    let headers = "Content-Type: application/json\nContent-Length: 1024\nHost: example.com";

    match parse_header_value(headers, "Content-Length") {
        Some(len) => println!("Body size: {} bytes", len),
        None => println!("No content length"),
    }

    match parse_header_value(headers, "X-Missing") {
        Some(val) => println!("Found: {}", val),
        None => println!("Header not found"),
    }
}
Body size: 1024 bytes
Header not found

Common Pitfalls

  • Using unwrap() on Option or Result in production code. It panics on None/Err. Use match, if let, unwrap_or, or ? instead.
  • Confusing &str and String. Think of &str as a view into string data and String as an owned buffer. Functions should usually accept &str.
  • Not using enums when you should. If a value can be one of several distinct states, use an enum. Do not reach for stringly-typed code or boolean flags.
  • Forgetting that integer overflow panics in debug mode. In release mode, it wraps. If you need specific behavior, use wrapping_add, checked_add, or saturating_add.
  • Treating Option<T> like a nullable pointer. It is a proper enum. Use the combinators (map, and_then, unwrap_or_else) instead of nesting if let statements.
  • Using as for numeric casts without checking. x as u8 truncates silently. Use u8::try_from(x) for safe conversion.

Key Takeaways

  • Rust integers have explicit sizes. i32 is the default. usize is for indexing.
  • &str is a borrowed string slice; String is an owned, growable string. Know when to use each.
  • Enums in Rust carry data per variant. They are algebraic data types, not integer constants.
  • Option<T> replaces null. Result<T, E> replaces exceptions. Both must be handled explicitly.
  • match is exhaustive — the compiler ensures every case is covered.
  • Favor combinators (map, and_then, unwrap_or) over nested pattern matching for cleaner code.