Data Types in Rust
Explore Rust's scalar types, compound types (tuples and arrays), and type aliases.
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.
| Length | Signed | Unsigned |
|---|---|---|
| 8-bit | i8 | u8 |
| 16-bit | i16 | u16 |
| 32-bit | i32 | u32 |
| 64-bit | i64 | u64 |
| 128-bit | i128 | u128 |
| arch | isize | usize |
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
}
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)); // 4 — wraps around past 255
println!("{}", x.saturating_add(10)); // 255 — clamps to the maximum
println!("{:?}", x.checked_add(10)); // None — signals overflow via Option
println!("{:?}", x.overflowing_add(10)); // (4, true) — value and overflow flag
}
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); // 1.7725
println!("{}", pow); // 1024
}
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); // false — logical AND
println!("{}", t || f); // true — logical OR
println!("{}", !t); // false — logical NOT
// Booleans are exactly 1 byte in memory
println!("{}", std::mem::size_of::<bool>()); // 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()); // true
println!("{}", '9'.is_numeric()); // true
println!("{}", letter.to_lowercase().next().unwrap()); // 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: () = ();
}
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); // 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]); // 2
println!("{}", primes.len()); // 5
// 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]; // [3, 5, 7] — no copy
println!("{:?}", middle);
}
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"); // prints this
}
}
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)); // 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); // hello, world!
println!("length: {}", s4.len()); // length in bytes, not Unicode characters
}
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: 3 (fractional part discarded)
println!("{}", i);
}
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); // 2147483647 — safely handled
}