You've got an i32 holding the number 42, and you need to pass it somewhere that wants a u8. In Python or JavaScript, the integer would just convert. In Rust, the compiler stops dead and tells you the types don't match. There's no implicit coercion between numeric types, no automatic widening, no silent narrowing. You have to spell out the conversion in code.
The keyword for this in Rust is as. It's short, it's everywhere, and it has a couple of sharp edges that are worth understanding the first time you meet it rather than the third or fourth time.
The basic shape
let x: i32 = 42;
// `as` casts x to u8. The compiler trusts you and emits the conversion.
let y: u8 = x as u8;
println!("{}", y); // 42
Read x as u8 as "treat x as if it were a u8." For values that fit, the result is what you'd expect. For values that don't fit, things get more interesting (more on that in a moment).
as works between primitive types: integers of various sizes (i8 through i128, u8 through u128, plus isize and usize), floats (f32, f64), bool, char, and raw pointers. It does not work for arbitrary types: you can't as a String into a Vec<u8>. For non-primitive conversions, Rust uses traits like From and Into.
What happens when the value doesn't fit
This is the part that surprises people. Casting between integer types of different sizes can lose information, and as does it without any complaint:
let big: i32 = 300;
// 300 doesn't fit in a u8 (max is 255). What happens?
let small: u8 = big as u8;
println!("{}", small); // 44, not an error
The reason: as performs a truncating cast. For integers, it keeps the low bits and throws the high bits away. 300 in binary is 0001 0010 1100. Take the low 8 bits and you get 0010 1100, which is decimal 44. Cast happens, no panic, no warning at runtime.
This is by design. Rust wants as to be a fast, predictable, side-effect-free operation: just a few CPU instructions, no bounds checking, no allocation. Decisions about what to do when a value doesn't fit are pushed to you.
The same logic applies to signed-to-unsigned and back:
let n: i8 = -1;
// -1 as i8 is bit pattern 1111_1111. Reinterpreted as u8, that's 255.
let u: u8 = n as u8;
println!("{}", u); // 255
// Going back the other way works the same.
let back: i8 = u as i8;
println!("{}", back); // -1
This isn't conversion in the math sense. It's reinterpretation of the bits in the new type's encoding. For unsigned-to-signed of the same width, it's literally the same bytes; the type just decides how to display them.
Float casts have their own quirks
Casting to and from floats follows specific rules:
// Float to integer: truncates toward zero (NOT rounds)
let pi: f64 = 3.7;
let i: i32 = pi as i32;
println!("{}", i); // 3, not 4
let neg: f64 = -3.7;
println!("{}", neg as i32); // -3
// Float that's out of range saturates to the integer's min/max
let huge: f64 = 1e30;
println!("{}", huge as i32); // 2147483647 (i32::MAX)
let tiny: f64 = -1e30;
println!("{}", tiny as i32); // -2147483648 (i32::MIN)
// NaN as integer: 0
println!("{}", f64::NAN as i32); // 0
That last one is worth keeping in your head. Casting NaN to an integer doesn't panic or return some special bit pattern, it just gives you 0. Up until Rust 1.45 this was actually undefined behavior; the language was tightened to make it deterministic.
Going integer-to-float can lose precision once your integer exceeds the float's mantissa size:
// f32 has 23 bits of mantissa, so it can't represent every i32 exactly
let n: i32 = 16_777_217; // 2^24 + 1
let f: f32 = n as f32; // 16777216.0 (off by one!)
println!("{}", f as i32); // 16777216
For most code this doesn't matter. When you're dealing with large integers and want exactness, use f64 instead, which gets you 52 bits of mantissa.
When as is the wrong tool
Rust has another way to convert between types: the From and Into traits. The big difference is that From/Into only exist when the conversion is infallible and lossless. There's a From<u8> for u32 (every u8 fits in a u32) but no From<u32> for u8 (because not every u32 fits).
// Lossless: u8 always fits in u32. Use From or .into().
let small: u8 = 42;
let big: u32 = small.into(); // or u32::from(small)
// Lossy: would truncate. There's no From impl. You must opt in via `as`
// or use TryFrom (which returns a Result you have to handle).
let big2: u32 = 300;
let small2: u8 = big2 as u8; // truncating: 44
let checked = u8::try_from(big2); // Err(_), proper handling
Rule of thumb: when both directions are safe and you want clarity, prefer From/Into. When the cast is lossy and you're sure it's fine (because you know the value's range), use as. When the cast might fail and you want the failure to be checked, use TryFrom/TryInto, which return a Result.
A lot of people reach for as reflexively because it's short. Once you've been bitten by a silent overflow, you appreciate TryFrom for what it gives you.
A more realistic example
Suppose you're parsing a binary file and the spec says a particular field is two bytes representing an unsigned 16-bit integer in big-endian order. You read the bytes, and you want to combine them into a u16:
// Read two bytes from anywhere; here we hard-code for the example.
let high: u8 = 0x12;
let low: u8 = 0x34;
// Promote each byte to u16 (lossless), shift the high one up, OR them together.
// We use `as u16` because u8 -> u16 is always safe.
let value: u16 = ((high as u16) << 8) | (low as u16);
println!("{:#x}", value); // 0x1234
This is a bread-and-butter pattern in binary parsing. The as u16 casts here are safe by construction: every u8 fits in a u16. We could write u16::from(high) for the same effect, and many people prefer that for the same reason: it visibly says "this conversion can't fail."
Compare to a place where as is dangerous:
// Reading a length field that's a u32, then using it as an index.
let length: u32 = 5_000_000_000; // 5 billion, too big for u32 actually,
// but humor me — read as u32 from a file.
let idx: usize = length as usize; // dangerous on 32-bit platforms!
On a 64-bit platform, usize is 64 bits and length as usize is fine. On a 32-bit platform, usize is 32 bits and... wait, the value is 5 billion, which doesn't fit in u32 either. But on a hypothetical platform where usize were narrower than u32, this cast would silently truncate. The right move here is usize::try_from(length) and handle the error.
Common pitfalls
Silent narrowing. Already discussed. If you cast from a larger integer to a smaller one with as, no warning. Use TryFrom if you want the safety net.
Casting bool. true as u8 is 1, false as u8 is 0. Useful in tight numeric code. Going the other way (5_u8 as bool) doesn't compile; as only goes from bool to numeric, not back.
Casting char. 'A' as u32 is 65, the Unicode codepoint. Going the other way (65_u32 as char) doesn't compile because not every u32 is a valid codepoint. Use char::from_u32(65) which returns Option<char>.
Pointer casts. as can convert raw pointers (*const T to *const U) but it's a footgun outside unsafe contexts. Don't reach for this unless you've thought carefully about layout compatibility.
A typical compiler error that nudges you toward the right approach:
error[E0277]: the trait bound `u8: From<u32>` is not satisfied
--> src/main.rs:3:24
|
3 | let small: u8 = u8::from(big);
| ^^^^^^^^ the trait `From<u32>` is not implemented for `u8`
|
= help: the following implementations were found:
<u8 as From<bool>>
<u8 as From<NonZeroU8>>
= note: required because of the requirements on the impl of `Into<u8>` for `u32`
That error is the language telling you "this conversion isn't always safe; either use as knowingly or use TryFrom and handle the failure case."