4.10Numeric conversions with as
This chapter has repeatedly slammed a door: no implicit conversions, not between i32 and u32, not even from i32 up to roomy, harmless i64 (lesson 4.2's quiz made sure you felt it). This lesson opens the door that was always meant to be used instead: explicit conversion, where you write the cast and thereby sign for its consequences.
If you're keeping score against C++: this single lesson replaces their entire implicit-conversion apparatus (promotions, narrowing rules, a chapter of "what converts to what when"), because a system with no automatic conversions has no automatic-conversion rules to memorize. There's one keyword, and it always means "I asked for this."
The as keyword
fn main() {
let small: i32 = 100;
let big: i64 = small as i64;
println!("{big}");
}
value as Type converts between numeric types (and a few friends, like the char-to-code casts of lesson 4.8). Conversions up in size, like this one, are exact: every i32 value fits in an i64, nothing can go wrong, and the cast is just paperwork the compiler demands so the reader can see the type change happening.
The interesting cases are the lossy directions, and as performs them without complaint, which is exactly why it must be written explicitly. Know the three behaviors:
Narrowing integer casts truncate. Converting to a smaller type keeps only the bits that fit:
fn main() {
let n: i32 = 300;
println!("{}", n as u8);
println!("{}", (-1i32) as u8);
}44
255
300 doesn't fit in a u8, so the excess bits are discarded, leaving 44 (300 − 256, the odometer arithmetic of lesson 4.4). And −1's bit pattern reinterpreted as unsigned is 255. Neither is an error, a panic, or a warning: as does what you asked, literally, and "what you asked" for a narrowing cast is "keep what fits."
Warning
That i32 suffix on (-1i32) is load-bearing, and the reason is a lovely collision of two things you know. Casting a bare negative literal to an unsigned type (-1 as u8) fails to compile: the cast supplies the literal's type evidence (lesson 4.9's rules at work), the 1 gets typed as u8, and then the minus sign has nothing legal to do, since unsigned values can't be negated (error E0600, whose help text drolly suggests you may have meant u8::MAX). Parentheses don't help; inference sees through them. A suffix pins the literal's type before the cast gets a vote. In real code this rarely bites, because real casts start from variables (like n above), which already have types.
Float-to-int casts drop the fraction, then saturate. The fractional part is discarded (toward zero, not rounded), and values beyond the target's range pin at its boundaries instead of wrapping:
fn main() {
println!("{}", 3.9 as i32);
println!("{}", (-3.9) as i32);
println!("{}", 1e10 as i32);
println!("{}", (-7.5) as u8);
println!("{}", f64::NAN as i32);
}3
-3
2147483647
0
0
3.9 becomes 3 (not 4; this is truncation, not rounding), ten billion pins at i32::MAX, negative values pin at a u8's floor of 0, and NaN converts to 0 by decree. Defined, predictable, and occasionally surprising, which is the recurring theme.
Int-to-float casts can lose precision. Every i32 survives the trip to f64 exactly, but lesson 4.5 told you an f64 only carries 15-ish significant digits, and big 64-bit integers have more:
fn main() {
let exact: i64 = 9_007_199_254_740_993;
println!("{}", exact as f64);
}9007199254740992
Off by one: the integer landed between representable floats and snapped to a neighbor. Rare in practice, memorable when met.
Warning
as never fails, and that's its sharp edge: it will quietly truncate, saturate, and approximate, because you signed for it. Before any narrowing cast, ask the lesson-3.6 defensive question: can this value actually be out of range here? If the answer is "maybe," a silent 44-from-300 is a bug in costume, and you want a conversion that can say no instead. Those exist (try_from, returning chapter 12's Result), and lesson 16.8 makes them routine. Until then: cast where you can argue it's safe, and leave a comment when the argument isn't obvious.
Key insight
The design pattern, third appearance this chapter (overflow policies, no truthiness, now casts): Rust doesn't forbid dangerous operations, it forbids unmarked ones. An implicit C++ conversion and a Rust as cast can compute the same wrong 44; the difference is that one is invisible in the source and the other is a keyword you typed, greppable in code review, with your name on it. Safety here isn't padding on the walls. It's a paper trail.
For completeness, the conversions this lesson is not about: numbers-from-text is parse (lesson 5.6), text-from-numbers is format! (5.5), and the ergonomic, can't-lose conversions between types get a nicer spelling (From/Into) in chapter 16. as is specifically the low-level numeric tool: blunt, total, explicit.
Quiz time
Question #1
Predict each output:
fn main() {
println!("{}", 9.99 as i32);
let n = 256;
println!("{}", n as u8);
println!("{}", 255 as u8);
println!("{}", (-1.0) as u32);
}Show solution
9
0
255
0
Truncation toward zero (9, not 10); 256 is one past the u8 odometer, wrapping the kept bits to 0; 255 fits exactly; −1.0 is below u32's floor and saturates to 0 (float-to-int saturates; only integer-to-integer narrowing wraps bits — that contrast is quiz-worthy on purpose).
Bonus marks if you wondered why the 256 took the scenic route through a variable: written directly, 256 as u8 doesn't compile! The literal-out-of-range check from lesson 4.3 inspects literals even in cast position and refuses. The variable smuggles the value past the literal police and into honest runtime wrap territory, which is exactly the compile-time-versus-runtime boundary this chapter keeps tracing.
Question #2
This program reports the size of a 5 GB file. Predict its output, then diagnose.
fn file_size_in_bytes() -> u64 {
5_000_000_000
}
fn main() {
let size = file_size_in_bytes();
let reported = size as u32;
println!("file is {reported} bytes");
}Show solution
file is 705032704 bytes
Five billion doesn't fit in a u32 (ceiling: 4,294,967,295), so the cast keeps the bits that fit: 5,000,000,000 − 4,294,967,296 = 705,032,704. A wildly wrong size, no error anywhere, and every file over 4 GiB triggers it. The fixes: keep it u64 end to end, or use a checked conversion that can report "doesn't fit" (lesson 16.8's try_from). The cast compiling cleanly is the point of the lesson: as made the risk visible in source; noticing it is the reviewer's job, currently yours.
Question #3
Why does Rust make you write as i64 even for the always-safe i32 → i64 direction?
Show solution
Uniformity over cleverness: with zero implicit conversions, every type change in the program is visible in the source, so readers never wonder where a value silently changed shape, and there's no rulebook of "which conversions are automatic" to learn or misremember. (Whether safe conversions deserve nicer ergonomics is a real question with a real answer: From/Into, chapter 16.)
One lesson left before the chapter quiz, and it answers a question chapter 2 planted: functions return exactly one value... so how do you return two?