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]