Rust Data Types

Casting Between Types

Rust never performs implicit numeric type conversions (coercions) between primitive types—even when widening a smaller integer into a larger one (u8 to u32). Instead, Rust provides a structured hierarchy of explicit type conversions ranging from infallible trait conversions (From / Into) and checked fallible conversions (TryFrom / TryInto) to low-level primitive casts (as).

Overview of Type Conversion Mechanisms in Rust

Mechanism Trait / Syntax Can It Lose Data or Overflow Silently? Return Type When to Use
Infallible Conversion T::from(x) / x.into() Never (only implemented when 100% lossless!) T Widening conversions (u8 -> u32, i16 -> i64, f32 -> f64, bool -> i32, u8 -> char)
Fallible Checked Conversion T::try_from(x) / x.try_into() Never (returns Err if out of range!) Result<T, TryFromIntError> Narrowing or cross-sign integer conversions (u64 -> u16, i32 <-> u32, usize <-> u32)
Sign Reinterpretation x.cast_signed() / x.cast_unsigned() Preserves exact bits ($n$-bit uN <-> iN) Counterpart type Explicit same-width bitwise sign cast (u32 <-> i32)
Bitwise Float/Byte Reinterpretation f32::to_bits / f32::from_bits, to_ne_bytes Preserves exact IEEE 754 / byte bits u32 / [u8; N] Low-level serialization or IEEE 754 bit manipulation
Primitive as Cast x as TargetType Yes (truncates high bits or saturates floats!) TargetType Only when truncation/wrapping or float-to-int saturation is explicitly desired
Decision Tree for Converting Between Numeric Types:

  Does every possible value of Source fit in Target? (e.g., u16 -> u64)
     ├── YES ──> Use `Target::from(x)` or `x.into()` (Lossless & compile-time guaranteed)
     └── NO  ──> Could an out-of-range value indicate a bug or bad input?
                   ├── YES ──> Use `Target::try_from(x)` or `x.try_into()` (Returns `Result`)
                   └── NO  ──> Do you intentionally want bit truncation / wrapping?
                                 └── YES ──> Use `x as Target` (or `.cast_signed()` / `.cast_unsigned()`)

1. Lossless Widening with From and Into

Whenever every possible value of type A can be represented exactly in type B without any loss of precision or sign, the standard library implements From<A> for B (which automatically provides Into<B> for A):

Source Type Lossless From / Into Target Types
u8 u16, u32, u64, u128, usize, i16, i32, i64, i128, isize, f32, f64, char
u16 u32, u64, u128, usize, i32, i64, i128, f32, f64
u32 u64, u128, i64, i128, f64
i8 i16, i32, i64, i128, isize, f32, f64
i16 i32, i64, i128, isize, f32, f64
i32 i64, i128, f64 (Note: i32 -> f32 is not implemented because f32 has only 24 mantissa bits!)
f32 f64
bool All integer types (0 for false, 1 for true)
char u32, u64, u128, String
fn main() {
    let small_byte: u8 = 200;

    // Lossless widening via `From::from`:
    let wide_int = u64::from(small_byte);
    let float_val = f64::from(small_byte);
    let ch = char::from(b'R');

    // Lossless widening via `.into()` when the destination type is annotated:
    let signed_32: i32 = small_byte.into();

    println!("wide_int = {wide_int}, float_val = {float_val:.1}, ch = '{ch}', signed_32 = {signed_32}");
}
wide_int = 200, float_val = 200.0, ch = 'R', signed_32 = 200

If you later refactor small_byte from u8 to u64, i32::from(small_byte) will immediately stop compiling rather than silently truncating high bits (as small_byte as i32 would).

2. Checked Narrowing and Cross-Sign Conversions (TryFrom and TryInto)

When converting from a wider integer to a narrower integer (u64 -> u16), between signed and unsigned (i32 <-> u32), or between fixed-width integers and pointer-sized integers (u64 <-> usize), the source value might not fit in the target type.

TryFrom and TryInto check the bounds at runtime and return Ok(converted) if the value fits, or Err(TryFromIntError) if it is out of range:

fn main() {
    let valid_port: i32 = 8080;
    let negative_port: i32 = -1;
    let huge_port: i32 = 70_000;

    println!("8080   -> u16: {:?}", u16::try_from(valid_port));
    println!("-1     -> u16: {:?}", u16::try_from(negative_port));
    println!("70_000 -> u16: {:?}", u16::try_from(huge_port));

    // Using `.try_into()` with `unwrap_or`:
    let clamped_or_default: u8 = 300_u32.try_into().unwrap_or(u8::MAX);
    println!("300_u32 as u8 (with fallback): {clamped_or_default}");
}
8080   -> u16: Ok(8080)
-1     -> u16: Err(TryFromIntError(()))
70_000 -> u16: Err(TryFromIntError(()))
300_u32 as u8 (with fallback): 255

Why usize Uses TryFrom for u64 and u32

Even on a 64-bit machine where usize is 64 bits wide, Rust does not implement From<u64> for usize or From<usize> for u64 because code using From must remain portable to 32-bit targets (such as wasm32) and 16-bit microcontrollers! Always use usize::try_from(val) (or val as usize when truncation on 32-bit is impossible or acceptable).

3. Bit-Level Conversions (to_bits, from_bits, and Endian Byte Arrays)

When serializing numbers to binary files or network sockets—or inspecting the raw IEEE 754 representation of a float—use explicit bit and byte conversion methods:

fn main() {
    // 1. Float <-> raw IEEE 754 bits (`u32` for `f32`, `u64` for `f64`):
    let f: f32 = 1.0;
    let raw_bits: u32 = f.to_bits();
    let reconstructed: f32 = f32::from_bits(raw_bits);
    println!("1.0_f32 bits = 0x{raw_bits:08X} -> {reconstructed:.1}");

    // 2. Integer <-> explicit Big-Endian (`be`) or Little-Endian (`le`) byte arrays:
    let packet_len: u32 = 0x1234_5678;
    let be_bytes: [u8; 4] = packet_len.to_be_bytes();
    let le_bytes: [u8; 4] = packet_len.to_le_bytes();

    println!("Big-endian bytes:    {be_bytes:02X?}");
    println!("Little-endian bytes: {le_bytes:02X?}");
    assert_eq!(u32::from_be_bytes(be_bytes), packet_len);
}
1.0_f32 bits = 0x3F800000 -> 1.0
Big-endian bytes:    [12, 34, 56, 78]
Little-endian bytes: [78, 56, 34, 12]