Skip to content
rusthacks.dev
Rust

Rust Data Types From Integers to Tuples and Arrays

A tour of Rust primitive and compound types: integers, floats, bool, char, tuples, arrays and slices, with their sizes, defaults and the casts that bite.

7 min read
Summarize in AI
Flat illustration for the post “Rust Data Types From Integers to Tuples and Arrays”, composed around ownership.

Rust asks you to be precise about numbers. There is no single int, no silent widening, and no implicit conversion between a byte and a count. The payoff is that you always know how big a value is and what happens when it does not fit. This post tours the built-in types with programs you can run: how large each one is, what the defaults are, how conversions behave, and where the compound types differ from lists in other languages. All output below comes from Rust 1.99, edition 2024.

How big each type is

The quickest way to learn the scalar types is to ask the compiler for their sizes and ranges:

src/main.rsrust
use std::mem::size_of;

fn main() {
    println!("i8={} i16={} i32={} i64={} i128={} isize={}",
        size_of::<i8>(), size_of::<i16>(), size_of::<i32>(), size_of::<i64>(), size_of::<i128>(), size_of::<isize>());
    println!("f32={} f64={} bool={} char={}",
        size_of::<f32>(), size_of::<f64>(), size_of::<bool>(), size_of::<char>());
    println!("(): {}  (u8, u32): {}  [u16; 5]: {}", size_of::<()>(), size_of::<(u8, u32)>(), size_of::<[u16; 5]>());
    println!("u8 range {}..={}", u8::MIN, u8::MAX);
    println!("i32 range {}..={}", i32::MIN, i32::MAX);
}
Text
i8=1 i16=2 i32=4 i64=8 i128=16 isize=8
f32=4 f64=8 bool=1 char=4
(): 0  (u8, u32): 8  [u16; 5]: 10
u8 range 0..=255
i32 range -2147483648..=2147483647

Integers come in signed (i8 to i128) and unsigned (u8 to u128) forms, and the number is the width in bits. isize and usize match the pointer width of the machine, 8 bytes here; they are the types of lengths and indexes. A bool takes a byte and a char takes four, because a char is a Unicode scalar value, not a byte. The unit type () has no size at all, and the tuple (u8, u32) is 8 bytes rather than 5 because the u32 must sit on a 4-byte boundary, so the compiler adds padding. The array [u16; 5] is exactly five times two bytes.

When you write a literal and nothing says what it is, Rust picks defaults:

src/main.rsrust
fn main() {
    let a = 42;          // no hint: i32
    let b = 3.14;        // no hint: f64
    let c: u64 = 42;
    println!("{} {} {}", std::any::type_name_of_val(&a), std::any::type_name_of_val(&b), std::any::type_name_of_val(&c));
}
Text
i32 f64 u64

42 becomes i32 and 3.14 becomes f64. If a later line uses the number where a u64 is required, inference changes the choice; if nothing decides it, the default applies. When the compiler cannot decide at all it stops with E0282, type annotations needed.

Floats are approximate

f32 and f64 follow the IEEE 754 standard, which is what every language and processor uses, so they carry its surprises:

src/main.rsrust
fn main() {
    let sum = 0.1 + 0.2;
    println!("{sum}");
    println!("{}", sum == 0.3);
    println!("{}", (sum - 0.3_f64).abs() < f64::EPSILON);
    println!("{}", 1.0 / 0.0);
    println!("{:?}", (0.0_f64 / 0.0).is_nan());
}
Text
0.30000000000000004
false
true
inf
true

0.1 + 0.2 is 0.30000000000000004 because neither 0.1 nor 0.2 has an exact binary representation, and == against 0.3 is therefore false. Compare floats with a tolerance, as the third line does (f64::EPSILON is the gap between 1.0 and the next float, so scale it for larger numbers). Division by zero gives infinity instead of a panic, and 0.0 / 0.0 is NaN, a value that is not equal to anything, itself included, so test it with is_nan. Never store money in a float; use integers of cents or a decimal type.

Conversions never happen quietly

The rule that surprises newcomers most is that Rust will not mix numeric types. Adding a u8 to a u32, which most languages accept, is an error:

src/main.rsrust
fn main() {
    let small: u8 = 10;
    let big: u32 = 1000;
    let total = small + big;
    println!("{total}");
}
Text
error[E0308]: mismatched types
 --> src/main.rs:4:25
  |
4 |     let total = small + big;
  |                         ^^^ expected `u8`, found `u32`

The full output also includes an E0277 saying u8 cannot be added to u32, because the operator itself has no implementation for the pair. The fix depends on what you mean. Widening is lossless, so a From conversion exists and cannot fail:

src/main.rsrust
fn main() {
    let small: u8 = 10;
    let big: u32 = 1000;
    let total = u32::from(small) + big;   // widening is lossless, so From exists
    println!("{total}");
}
Text
1010

For conversions that can lose information, you choose the behaviour. The as keyword is the blunt tool:

src/main.rsrust
fn main() {
    let big: i32 = 300;
    println!("300 as u8   = {}", big as u8);
    println!("-1 as u32   = {}", -1_i32 as u32);
    println!("3.99 as i32 = {}", 3.99_f64 as i32);
    println!("1e10 as i32 = {}", 1e10_f64 as i32);

    println!("{:?}", u8::try_from(300_i32));
    println!("{:?}", u8::try_from(200_i32));
    println!("{:?}", i8::try_from(200_u8).is_err());
}
Text
300 as u8   = 44
-1 as u32   = 4294967295
3.99 as i32 = 3
1e10 as i32 = 2147483647
Err(TryFromIntError(PosOverflow))
Ok(200)
true

Read these results carefully. 300 as u8 is 44, because only the low eight bits survive. -1 as u32 reinterprets the bits and gives 4294967295. 3.99 as i32 truncates toward zero, giving 3. And 1e10 as i32 saturates at the maximum i32, giving 2147483647. None of these is an error and none prints a warning, which is why as is where bugs in numeric code live. try_from is the checked alternative: u8::try_from(300_i32) returns Err(TryFromIntError(PosOverflow)) and u8::try_from(200_i32) returns Ok(200), so out-of-range values become something you must handle. The pattern to follow is as only when truncation is what you want, and try_from for anything that came from outside the program. The traits behind this are in From and Into.

Tuples, arrays and chars

The compound types are small and fixed:

src/main.rsrust
fn main() {
    let point: (i32, f64, &str) = (10, 2.5, "origin");
    let (x, y, label) = point;
    println!("{x} {y} {label}");
    println!("first = {}, third = {}", point.0, point.2);

    let weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri"];
    println!("{} days, first {}, last {}", weekdays.len(), weekdays[0], weekdays[weekdays.len() - 1]);

    let zeros = [0u8; 4];
    println!("{zeros:?}");

    let c = 'é';
    println!("{c} is {} bytes as a char, {} bytes in a str", std::mem::size_of_val(&c), "é".len());
}
Text
10 2.5 origin
first = 10, third = origin
5 days, first Mon, last Fri
[0, 0, 0, 0]
é is 4 bytes as a char, 2 bytes in a str

A tuple groups values of different types. You take it apart with a pattern, let (x, y, label) = point;, or reach one field with .0. An array holds values of one type with a length that is part of the type: [&str; 5] and [&str; 4] are different types, and [0u8; 4] builds four zeros. For a collection that grows you want a Vec, compared with the other containers in Rust collections compared.

The last line shows why char is four bytes. 'é' is one character and occupies four bytes as a char, but only two bytes when it sits inside a string, because strings are stored as UTF-8. That is why "é".len() is 2 and why indexing a string by position is not allowed.

Indexing fails loudly

Index past the end of an array and the program does not read garbage; it stops:

src/main.rsrust
fn main() {
    let days = [1, 2, 3];
    let index = days.len();
    println!("{}", days[index]);
}
Text
thread 'main' panicked at src/main.rs:4:20:
index out of bounds: the len is 3 but the index is 3
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

The panic names the length and the index, which makes the cause obvious. When the index is a constant that the compiler can see, it refuses to build the program at all:

src/main.rsrust
fn main() {
    let days = [1, 2, 3];
    println!("{}", days[5]);
}
Text
error: this operation will panic at runtime
 --> src/main.rs:3:20
  |
3 |     println!("{}", days[5]);
  |                    ^^^^^^^ index out of bounds: the length is 3 but the index is 5
  |
  = note: `#[deny(unconditional_panic)]` on by default

error: aborting due to 1 previous error

That error is a deny-by-default lint, unconditional_panic. For indexes computed at run time, use get, which returns an Option instead of panicking: days.get(index). The cost of the bounds check is covered in bounds checks.

Picking a type in practice

  • Use usize for lengths and indexes, because the standard library does.
  • Use i32 or i64 for counts and arithmetic where the sign matters, and u32/u64 where a negative value is meaningless.
  • Use u8 for bytes and f64 for floating point unless memory or a GPU says otherwise.
  • Let literals default. Add a suffix (10u8) or an annotation (let n: u16 = 10;) only where the type matters.
  • Treat each as in your code as a small decision and write a comment when it is a lossy one.

Overflow is the one numeric topic this post leaves out on purpose, because its behaviour depends on the build: integer overflow in debug and release covers it.

Where this strictness costs you

Mixed arithmetic is verbose. Code that computes with several integer widths ends up with a lot of u64::from(...) and as f64, and that noise is the price of knowing what each line does. If you are porting numerical code and drowning in casts, pick one working type per module and convert at the edges. For types with units, such as meters and seconds, a wrapper type is safer than any of the primitives, as shown in the newtype pattern.

Taggedrusttypes
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.