Rust · lesson 4 of 30
Beginner
15- 1 Introduction to Rust
- 2 Setting Up Rust
- 3 Variables in Rust
- 4 Data Types in Rust
- 5 Operators in Rust
- 6 Control Flow in Rust
- 7 Functions in Rust
- 8 Ownership in Rust
- 9 Borrowing and References
- 11 Structs in Rust
- 12 Enums in Rust
- 13 Pattern Matching in Rust
- 17 Collections in Rust
- 25 Testing in Rust
- 26 File I/O in Rust
Intermediate
9Advanced
6Data 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.
| 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
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
-
Change
let b: u8 = 255;tolet 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 thewrapping_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. -
What does
f64::NAN == f64::NANevaluate to? Write a small program to check, then explain whyif x == f64::NANis 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()printstruefor the same value, confirming it. Because== f64::NANis alwaysfalseregardless ofx, that comparison silently does nothing useful — always use.is_nan()instead. -
Cast a negative float to an unsigned integer:
let y = (-1.0_f64) as u32;. Does this panic, wrap, or something else? Compare againstas i32on the same value.Solution
(-1.0_f64) as u32evaluates to0— Rust’sascasts 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 like1e20_f64 as u32saturates the other direction, tou32::MAX(4294967295).as i32on-1.0gives-1, since-1.0fitsi32’s range fine. -
The
min_maxfunction panics if called with an empty slice (slice[0]on an empty slice). Reproduce the panic, then rewrite the function to returnOption<(i32, i32)>instead, returningNonefor an empty input.Solution
thread 'main' panicked at ...: index out of bounds: the len is 0 but the index is 0. TheOption-returning version starts withif slice.is_empty() { return None; }before the loop, and wraps the final tuple inSome((min, max))— the caller then handles both cases withmatchorif letinstead of risking a panic on empty input. -
Type aliases are transparent —
MetersandSecondsare still both justf64, so nothing stops you callingspeed(t, d)with the arguments swapped. RewriteMetersas 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. Withstruct Meters(f64);andstruct Seconds(f64);(newtypes), the same swapped call fails witherror[E0308]: arguments to this function are incorrect(expected \Meters`, found `Seconds`, plus ahelp: 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.