Skip to main content
Rust · lesson 4 of 30
Rust beginner Lesson 4 of 30

Data Types in Rust

Explore Rust's scalar types, compound types (tuples and arrays), and type aliases.

By SkillByExample · Updated

Scalar Types

Scalar types represent a single value. Rust has four primary scalar kinds: integers, floating-point numbers, booleans, and characters. Every value in a Rust program has a type, and the compiler uses these types to catch misuse at compile time — there is no runtime type coercion or implicit conversion between scalar types.

Integers

Rust gives you precise control over integer size and signedness, which matters when you are targeting embedded hardware, writing network protocols, or working with binary formats. The default integer type — i32 — is the right choice when you do not have a specific reason to pick something else.

LengthSignedUnsigned
8-biti8u8
16-biti16u16
32-biti32u32
64-biti64u64
128-biti128u128
archisizeusize

isize/usize match the pointer width of the target platform (64-bit on modern systems). They are the natural type for indexing slices and collections because array indices must match the platform’s address size.

fn main() {
    let a: i32  = -2_147_483_648; // minimum i32 value
    let b: u8   = 255;            // maximum u8 value
    let c: i64  = 9_223_372_036_854_775_807; // maximum i64
    let index: usize = 0;         // always use usize for array/slice indices

    // Integer overflow is a panic in debug mode — the compiler won't silently corrupt data
    // let overflow: u8 = 256; // ERROR: literal out of range

    println!("a={a}, b={b}, c={c}, index={index}");
}
$ rustc data_types_int.rs && ./data_types_int
a=-2147483648, b=255, c=9223372036854775807, index=0

Rust panics on integer overflow in debug builds and wraps silently in release builds. When you need predictable wrap/clamp behaviour, use the explicit methods:

fn main() {
    let x: u8 = 250;
    println!("{}", x.wrapping_add(10));    // wraps around past 255
    println!("{}", x.saturating_add(10));  // clamps to the maximum
    println!("{:?}", x.checked_add(10));   // signals overflow via Option
    println!("{:?}", x.overflowing_add(10)); // value and overflow flag
}
$ rustc data_types_overflow.rs && ./data_types_overflow
4
255
None
(4, true)

Floating-Point Numbers

Rust has two floating-point types following IEEE 754. f64 is the default because on modern 64-bit CPUs it is the same speed as f32 while offering significantly more precision. Reach for f32 only when you have a specific reason — GPU computation, tight memory budgets, or interoperating with C APIs that use float.

fn main() {
    let f32_val: f32 = 3.14;
    let f64_val: f64 = 3.141_592_653_589_793; // default floating-point type

    // Standard math operations are methods on the type
    let sqrt  = f64_val.sqrt();
    let abs   = (-2.5_f64).abs();
    let ceil  = 2.1_f64.ceil();    // 3.0 — round up
    let floor = 2.9_f64.floor();   // 2.0 — round down
    let pow   = 2.0_f64.powi(10);  // 1024.0 — integer exponent

    println!("{:.4}", sqrt);
    println!("{}", pow);
    println!("f32_val={f32_val}, abs={abs}, ceil={ceil}, floor={floor}");
}
$ rustc data_types_float.rs && ./data_types_float
1.7725
1024
f32_val=3.14, abs=2.5, ceil=3, floor=2

Booleans

The bool type has exactly two values: true and false. Booleans are the result of comparison and logical expressions, and they are the condition type for if and while. Unlike C, Rust does not allow integers to stand in for booleans — the types are kept strictly separate.

fn main() {
    let t: bool = true;
    let f: bool = false;

    println!("{}", t && f);  // logical AND
    println!("{}", t || f);  // logical OR
    println!("{}", !t);      // logical NOT

    // Booleans are exactly 1 byte in memory
    println!("{}", std::mem::size_of::<bool>());
}
$ rustc data_types_bool.rs && ./data_types_bool
false
true
false
1

Characters

char represents a single Unicode scalar value, not a byte. This distinction matters: a char is always 4 bytes, can hold any Unicode code point, and is the correct type for working with text character by character. String iterating and indexing use char rather than bytes.

fn main() {
    let letter: char = 'A';
    let emoji: char  = '😊';  // emoji are valid chars
    let cjk: char    = '中';   // Chinese/Japanese/Korean characters too

    println!("{} {} {}", letter, emoji, cjk);
    println!("{}", std::mem::size_of::<char>()); // 4 bytes always
    println!("{}", letter.is_alphabetic());
    println!("{}", '9'.is_numeric());
    println!("{}", letter.to_lowercase().next().unwrap());
}
$ rustc data_types_char.rs && ./data_types_char
A 😊 中
4
true
true
a

Compound Types

Compound types group multiple values into one type. Rust’s two built-in compound types — tuples and arrays — differ in one key way: tuples can hold different types, arrays hold a single type.

Tuples

Tuples group a fixed number of values that can be of different types. They are useful for returning multiple values from a function without defining a dedicated struct, and for quick ad-hoc groupings. Elements are accessed by zero-based index with dot notation, or by destructuring.

fn main() {
    let point: (f64, f64) = (3.0, -1.5);
    let rgb: (u8, u8, u8) = (255, 128, 0);
    let mixed: (i32, bool, &str) = (42, true, "hello");

    // Access individual fields by index
    println!("x={}, y={}", point.0, point.1);

    // Destructure into named bindings — often cleaner than indexed access
    let (r, g, b) = rgb;
    println!("r={}, g={}, b={}", r, g, b);

    // The unit tuple () — zero elements, used as the "no return value" type
    let unit: () = ();
    println!("mixed={:?}, unit={:?}", mixed, unit);
}
$ rustc data_types_tuple.rs && ./data_types_tuple
x=3, y=-1.5
r=255, g=128, b=0
mixed=(42, true, "hello"), unit=()

Tuples are particularly convenient for returning multiple values from a function without the overhead of defining a struct:

fn min_max(slice: &[i32]) -> (i32, i32) {
    let mut min = slice[0];
    let mut max = slice[0];
    for &val in &slice[1..] {
        if val < min { min = val; }
        if val > max { max = val; }
    }
    (min, max) // return both values in a tuple
}

fn main() {
    let data = [3, 1, 4, 1, 5, 9, 2, 6];
    let (lo, hi) = min_max(&data); // destructure the result immediately
    println!("min={}, max={}", lo, hi);
}
$ rustc data_types_minmax.rs && ./data_types_minmax
min=1, max=9

Arrays

Arrays have a fixed size known at compile time and are stored entirely on the stack. This makes them very fast — no heap allocation, no indirection — but it means the size must be a constant. When you need a dynamically-sized collection, use Vec<T> instead.

fn main() {
    // Type annotation syntax: [ElementType; Length]
    let primes: [u32; 5] = [2, 3, 5, 7, 11];

    // Initialise all elements to the same value
    let zeros = [0u8; 256]; // 256 bytes, all zero — common for buffers

    // Access by index — bounds are checked at runtime (panics, not UB)
    println!("{}", primes[0]);
    println!("{}", primes.len());

    // Iterate over borrowed elements
    for p in &primes {
        print!("{} ", p);
    }
    println!();

    // Slices — a borrowed view into a contiguous sequence
    let middle: &[u32] = &primes[1..4]; // no copy
    println!("{:?}", middle);
    println!("zeros.len()={}", zeros.len());
}
$ rustc data_types_array.rs && ./data_types_array
2
5
2 3 5 7 11 
[3, 5, 7]
zeros.len()=256

Out-of-bounds access panics at runtime (not undefined behaviour like in C). For safe access when the index might be out of range, use .get() which returns an Option:

fn main() {
    let a = [1, 2, 3];
    // let x = a[5]; // panics: index out of bounds at runtime

    // Safe alternative — returns None instead of panicking
    if let Some(x) = a.get(5) {
        println!("{}", x);
    } else {
        println!("index out of range");
    }
}
$ rustc data_types_get.rs && ./data_types_get
index out of range

Type Aliases

type creates an alias — a new name for an existing type. It does not create a distinct type; the two names are fully interchangeable. Aliases improve readability by making intent clear, especially in function signatures where raw f64 parameters do not tell the reader what the value represents.

type Meters = f64;
type Seconds = f64;
type Velocity = f64;

// The signature now reads like documentation
fn speed(distance: Meters, time: Seconds) -> Velocity {
    distance / time
}

fn main() {
    let d: Meters = 100.0;
    let t: Seconds = 9.58;
    println!("{:.2} m/s", speed(d, t));
}
$ rustc data_types_alias.rs && ./data_types_alias
10.44 m/s

Type aliases improve readability and document intent. For stricter type safety — preventing accidental mixing of Meters and Seconds at the type level — use the newtype pattern (a tuple struct with one field) instead. Aliases are transparent to the compiler; newtypes are distinct types.

The str and String Types

Rust has two string types that serve different purposes. Understanding when to use each one is important because they appear constantly in real code.

fn main() {
    // &str — a string slice; a borrowed reference to UTF-8 encoded string data
    // String literals are &str and live in the compiled binary
    let s1: &str = "hello, world";

    // String — heap-allocated, growable, owned UTF-8 string
    // Use String when you need to build or modify string content at runtime
    let s2: String = String::from("hello");
    let s3 = "world".to_string(); // convert &str to String
    let s4 = format!("{}, {}!", s2, s3); // format! always returns a String

    println!("{}", s4);
    println!("length: {}", s4.len()); // length in bytes, not Unicode characters
    println!("s1={s1}");
}
$ rustc data_types_str.rs && ./data_types_str
hello, world!
length: 13
s1=hello, world

Type Casting with as

Rust does not implicitly coerce numeric types. Every conversion must be explicit. The as keyword performs primitive casts, truncating or wrapping as needed. Because as can silently lose data, prefer the safer From/Into or TryFrom/TryInto traits for non-trivial conversions.

fn main() {
    let x: i32 = 1000;
    let y = x as u8;   // truncates: 1000 % 256 = 232 (data loss, but intentional)
    let z = x as f64;  // widens losslessly: 1000.0

    println!("{} {} {}", x, y, z);

    let f: f64 = 3.99;
    let i = f as i32;  // truncates toward zero: fractional part discarded
    println!("{}", i);
}
$ rustc data_types_cast1.rs && ./data_types_cast1
1000 232 1000
3

For safe, checked conversions that return an error instead of truncating:

fn main() {
    let n: i64 = 1000;
    let m = i32::try_from(n).unwrap_or(i32::MAX); // returns Err if value doesn't fit
    println!("{}", m); // 1000

    let too_big: i64 = 3_000_000_000;
    let clamped = i32::try_from(too_big).unwrap_or(i32::MAX);
    println!("{}", clamped);
}
$ rustc --edition 2021 data_types_cast2.rs && ./data_types_cast2
1000
2147483647

TryFrom/TryInto are in the prelude automatically under the 2021 edition (cargo new projects default to it) — compiling directly with plain rustc and no --edition flag falls back to the 2015 edition and fails with E0599: no associated function or constant named try_from found for type i32, since the trait isn’t in scope there.

Practice

  1. Change let b: u8 = 255; to let b: u8 = 256; in the integer example. What does the compiler say, and why does it catch this at compile time instead of at runtime like the overflow-method examples?

    Solution

    error: literal out of range for \u8`—note: the literal `256` does not fit into the type `u8` whose range is `0..=255`(it's a harddeny-level lint, overflowing_literals, not a warning). This is different from the wrapping_add/checked_addexamples because256is a *literal* the compiler can evaluate at compile time — no need to wait for runtime to know it doesn't fit. A value computed at runtime (likex.wrapping_add(10)`) can’t be checked until the program actually runs, which is exactly why those methods exist.

  2. What does f64::NAN == f64::NAN evaluate to? Write a small program to check, then explain why if x == f64::NAN is always the wrong way to test for NaN.

    Solution

    false — NaN is defined by IEEE 754 to compare unequal to everything, including itself. x.is_nan() prints true for the same value, confirming it. Because == f64::NAN is always false regardless of x, that comparison silently does nothing useful — always use .is_nan() instead.

  3. Cast a negative float to an unsigned integer: let y = (-1.0_f64) as u32;. Does this panic, wrap, or something else? Compare against as i32 on the same value.

    Solution

    (-1.0_f64) as u32 evaluates to 0 — Rust’s as casts from float to int saturate at the target type’s bounds rather than wrapping or invoking undefined behaviour (this changed in Rust 1.45; older code assuming C-style UB is out of date). A value like 1e20_f64 as u32 saturates the other direction, to u32::MAX (4294967295). as i32 on -1.0 gives -1, since -1.0 fits i32’s range fine.

  4. The min_max function panics if called with an empty slice (slice[0] on an empty slice). Reproduce the panic, then rewrite the function to return Option<(i32, i32)> instead, returning None for an empty input.

    Solution

    thread 'main' panicked at ...: index out of bounds: the len is 0 but the index is 0. The Option-returning version starts with if slice.is_empty() { return None; } before the loop, and wraps the final tuple in Some((min, max)) — the caller then handles both cases with match or if let instead of risking a panic on empty input.

  5. Type aliases are transparent — Meters and Seconds are still both just f64, so nothing stops you calling speed(t, d) with the arguments swapped. Rewrite Meters as a newtype (struct Meters(f64);) instead of a type alias, and see what the compiler does with the same swapped call.

    Solution

    With the alias, speed(t, d) compiles silently — a real bug, since seconds are now being passed where meters were expected, with no error. With struct Meters(f64); and struct Seconds(f64); (newtypes), the same swapped call fails with error[E0308]: arguments to this function are incorrect (expected \Meters`, found `Seconds`, plus a help: swap these arguments` suggestion) — the newtype pattern turns a silent logic bug into a compile error, which is exactly the tradeoff the lesson’s prose calls out above.

Frequently Asked Questions

What is the default integer type in Rust?
i32. It is generally the fastest integer type on modern hardware and is the type inferred when you write a plain integer literal.
What is the difference between an array and a Vec in Rust?
Arrays have a fixed size known at compile time and live on the stack. Vec is a heap-allocated, dynamically-sized collection.
Can Rust tuples hold different types?
Yes. Tuples are heterogeneous — each element can be a different type.