03 / 037 min read

Data Types

Rust's type system from the ground up — integer sizes and overflow, floats, booleans, characters, tuples, and arrays, with the rules that govern each.

Rust Is Statically Typed

Every value in Rust has a type known at compile time. The compiler uses this information to guarantee memory safety and catch bugs before your program runs. You've seen type inference at work — now let's understand what types actually exist.

Rust's types split into two categories:

  • Scalar types — a single value: integers, floats, booleans, characters
  • Compound types — multiple values grouped: tuples, arrays

Integer Types

Integers come in signed (i) and unsigned (u) variants, across six sizes:

LengthSignedUnsigned
8-biti8u8
16-biti16u16
32-biti32u32
64-biti64u64
128-biti128u128
archisizeusize
  • Signed integers can be negative. An i8 holds -128 to 127.
  • Unsigned integers are always non-negative. A u8 holds 0 to 255.
  • isize/usize match the pointer size of the target architecture (64-bit on modern machines). usize is used for indexing and collection lengths.
let a: i32 = -1_000_000;    // underscores improve readability
let b: u8  = 255;
let c: usize = arr.len();   // always the right type for indexing

Default: When you write let x = 5; with no annotation, Rust infers i32. It's the fastest integer type on most platforms and a safe default.

Integer Literals

Rust supports multiple literal formats:

let decimal     = 1_000_000;   // underscores anywhere for readability
let hex         = 0xff;        // 255
let octal       = 0o77;        // 63
let binary      = 0b1010_0001; // 161
let byte        = b'A';        // u8 only: 65

Integer Overflow

In debug mode (the default with cargo build), integer overflow panics at runtime:

let x: u8 = 255;
let y = x + 1;  // thread 'main' panicked: attempt to add with overflow

In release mode (cargo build --release), overflow wraps silently (two's complement). This matches C behavior but can produce silently wrong results.

If you need explicit wrapping, saturating, or checked arithmetic, use the methods Rust provides:

let x: u8 = 255;
x.wrapping_add(1)    // 0   — wraps around
x.saturating_add(1)  // 255 — clamps at max
x.checked_add(1)     // None — returns Option<u8>
x.overflowing_add(1) // (0, true) — value + did it overflow?

These make the intent explicit and work the same in both debug and release.


Floating-Point Types

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

TypeSizePrecision
f3232-bit~7 decimal digits
f6464-bit~15 decimal digits
let x = 2.0;        // f64 by default
let y: f32 = 3.14;  // explicit f32

Use f64 by default — it's the same speed as f32 on modern CPUs and significantly more precise. Use f32 only when memory layout matters (GPU data, packed structs).

Floating-Point Gotchas

let x = 0.1_f64 + 0.2_f64;
println!("{x}");  // 0.30000000000000004 — not 0.3

This is not a Rust bug. It's how IEEE 754 works. Never compare floats with == for exact equality — use an epsilon comparison:

fn approx_eq(a: f64, b: f64) -> bool {
    (a - b).abs() < f64::EPSILON
}

Basic Arithmetic

let sum        = 5 + 10;       // i32: 15
let difference = 95.5 - 4.3;   // f64: 91.2
let product    = 4 * 30;       // i32: 120
let quotient   = 56.7 / 32.2;  // f64: 1.760...
let remainder  = 43 % 5;       // i32: 3

Integer division truncates toward zero: 7 / 2 == 3, not 3.5.


Boolean Type

let t = true;
let f: bool = false;  // explicit annotation
 
if t {
    println!("it's true");
}

Booleans are 1 byte. Unlike C, Rust does not treat integers as booleans — if 1 {} is a compile error. Conditions must be explicitly bool.


Character Type

char in Rust is a Unicode scalar value — 4 bytes, not 1. It can represent any Unicode character, not just ASCII.

let c  = 'z';
let z  = 'ℤ';       // mathematical integer sign
let heart = '❤';   // emoji
let cat = '🐱';

Use single quotes for char, double quotes for &str/String. They are different types and not interchangeable.

let letter: char = 'a';
let word: &str   = "hello";

Tuple Type

A tuple groups values of different types into one compound value. The length is fixed at compile time.

let tup: (i32, f64, bool) = (500, 6.4, true);

Destructuring unpacks a tuple into named bindings:

let (x, y, z) = tup;
println!("y = {y}");  // 6.4

Index access uses .0, .1, etc.:

let x = tup.0;   // 500
let y = tup.1;   // 6.4

The unit type () is an empty tuple. It's what functions return when they have no explicit return value — similar to void in C, but it's an actual type with one actual value.

fn do_nothing() -> () {}   // explicit unit return
fn also_nothing() {}        // implicit unit return — same thing

Array Type

An array holds multiple values of the same type. The length is fixed at compile time and stored on the stack.

let arr = [1, 2, 3, 4, 5];          // type: [i32; 5]
let zeros = [0; 10];                  // ten zeros: [0, 0, 0, ..., 0]
let months: [&str; 12] = [
    "January", "February", "March",
    "April",   "May",       "June",
    "July",    "August",    "September",
    "October", "November",  "December",
];

Access by index:

let first  = arr[0];   // 1
let second = arr[1];   // 2

Out-of-bounds access panics at runtime (in debug mode) or is checked via bounds checking (always on, even in release):

let arr = [1, 2, 3];
let i = 10;
println!("{}", arr[i]);  // thread 'main' panicked: index out of bounds

Rust never silently reads out-of-bounds memory — this is one of the safety guarantees enforced at runtime (the compile-time checks catch many cases, but not runtime indices).

Array vs Vec

[T; N] (array) has a fixed length known at compile time, stored on the stack. Vec<T> has a variable length, stored on the heap.

Use arrays for fixed-size, stack-allocated data (pixel buffers, small lookup tables). Use Vec for anything whose size varies at runtime. You'll learn Vec in the Collections article.


Type Casting

Rust does not implicitly coerce between numeric types. You must use as for explicit casts:

let x: i32 = 1000;
let y = x as u8;    // 232 — truncates, wraps around
let z = x as f64;   // 1000.0
 
let f = 3.99_f64;
let i = f as i32;   // 3 — truncates toward zero, never rounds

as casts are infallible but lossy — they never panic but can silently truncate. For safe numeric conversions with error handling, use TryFrom/TryInto:

use std::convert::TryFrom;
 
let big: i32 = 1000;
let small = u8::try_from(big);  // Err(TryFromIntError)
 
let fits: i32 = 100;
let ok = u8::try_from(fits);    // Ok(100)

Summary

TypeDescriptionDefault
i8i128, isizeSigned integersi32
u8u128, usizeUnsigned integers
f32, f64Floating-pointf64
boolBoolean
charUnicode scalar (4 bytes)
(T, U, ...)Tuple — fixed, mixed types
[T; N]Array — fixed length, same type

Key rules:

  • No implicit type coercion — use as or TryFrom
  • Integer overflow panics in debug, wraps in release — use checked methods for control
  • char is 4 bytes (Unicode), not 1 byte (ASCII)
  • Arrays are fixed-size stack values; Vec is the growable heap alternative

Next: Functions — how to define and call functions, what ownership means for parameters and return values, and how expressions vs statements shape Rust's syntax.