When is it okay to cast types with `as`?

6 min read July 11, 2024 updated: July 14, 2024 #rust #programming

Update: Added a section about using as to cast to wider types.

swan asked me this after reading an earlier version of "Surprises with Rust's as". I thought that post had already gotten long, so I decided to break this part out. I recommend reading that post first if you want the full context.

This is a complicated question. I don't think there are absolute answers in software engineering — and we should probably beware people who try to give them. Almost every engineering decision requires trade-offs, and this is 100% the case with using TryFrom instead of as. TryFrom gives us more certainty that this particular part of our codebase won't cause us surprises, but we trade away simplicity and convenience.

For me, that trade-off is worth it. When my code — or any code I have to work with — encounters something unexpected that it can't handle correctly, I prefer it to fail as fast as possible and give me as much relevant context as possible,1 instead of chugging on and doing strange things (and making me go on a wild chase to find the cause). So, I am clearly biased against as.

With that said, casting types with as is probably okay if:

So, in short, something like this:

// This should be okay because we're only ever dealing with ASCII-inputs.
// TODO: Use `TryFrom`/add error handling if we ever have to accept a wider range of inputs!
let byte = some_char as u8;

Otherwise, if in doubt, I don't think it would be a bad idea to use TryFrom instead.

I appreciate swan's feedback that led to this addendum, and hope that it helps!

§Using as to widen types

As Ryan Goldstein helpfully pointed out in response to an earlier version of this post, casting from a smaller type to a larger one (for example, u8 to u16, or f32 to f64) will always produce expected results:

fn get_number() -> u8 {
    // This could be a result of some calculation.
    32
}

fn get_half_floor(number: u16) -> u16 {
    number / 2
}

fn main() {
    let number = get_number();
    let result = get_half_floor(number as u16);
    dbg!(result);
}
[src/main.rs:13:5] result = 16

However, I would still avoid using as here.

Compared to the above example, the latter would both give us arguably better ergonomics (we can let Rust infer the type we want to convert to, instead of having to specify it manually):

// --snip--

fn main() {
    let number = get_number();
    let result = get_half_floor(number.into());
    dbg!(result);
}

and, importantly, if the type of number were to change from under us, we would get a compiler error :

fn get_number() -> u32 { 
    // --snip--
}

// --snip--

fn main() {
    let number = get_number();
    let result = get_half_floor(number.into());
    dbg!(result);
}
error[E0277]: the trait bound `u16: From<u32>` is not satisfied
--> src/main.rs:12:40
|
12 |     let result = get_half_floor(number.into());
|                                        ^^^^ the trait `From<u32>` is not implemented for `u16`, which is required by `u32: Into<_>`
|
= help: the following other types implement trait `From<T>`:
<u16 as From<Char>>
<u16 as From<bool>>
<u16 as From<u8>>
= note: required for `u32` to implement `Into<u16>`

For more information about this error, try `rustc --explain E0277`.

I don't think that error is very helpful in itself,2 but it would force us to investigate what is going on and update our code, instead of running into potentially surprising and unwanted behavior at runtime, which we might get if we stuck with as.3


§Footnotes

1

What "fail" means will depend on the context. It could be just "crash with a clear error message", or, more likely in a complex system, log a clear error message and bail out of whatever procedure we attempted to do with that unexpected input.

2

When I get the the trait bound ... is not satisfied errors, Rust telling me what other types implement the trait usually doesn't help me much, because I probably can't just use one of those types instead. In my experience, getting that error almost always means I made some mistake in the logic, and used something in the wrong place or forgot a step. As a newcomer, I found the help: the following other types implement trait especially confusing (and not helpful), because not only did I get a compiler error, that error also came with a lot of information that I had to mentally process, and which rarely led me to the solution I needed. But there are probably situations I'm not thinking of where knowing what other types implement a trait you're trying to use might get you closer to what you want. If you are reading this post and have one in mind, please send me a note, so I can update this post with an example!

3

And, because lossless conversions for primitive types internally use as and are almost always inlined, you most likely won't pay any performance penalty for using them — and if you know they are affecting your performance, you probably already know why you need to use as directly.

If you have feedback, I would love to hear from you! Please use one of the links below to get in touch.