Type Conversion

Every value in Rust has a fixed, specific type, and Rust never silently converts one type into another the way JavaScript or C sometimes does. If you have an i32 and a function expects an f64, or you have a String and need a number, you must convert it explicitly. That might sound like extra work, but it is one of Rust’s biggest safety wins: implicit conversions are a common source of bugs elsewhere (silent truncation, surprising string coercion, comparing incompatible types), and Rust refuses to guess for you. This lesson covers every major way to convert between types: the as operator, the From and Into traits, the fallible TryFrom and TryInto traits, and parsing strings with .parse().

Overview: How Type Conversion Works in Rust

Rust groups conversions around one core question: can the conversion fail? An i32 converting to an i64 can never fail — every possible i32 value fits inside an i64. But an i64 converting to an i32 can fail, because plenty of i64 values (like five billion) do not fit in 32 bits. Rust gives you a different tool for each case, and choosing the right one is the core skill of this lesson.

The as keyword performs a primitive cast. It works between numeric types, bool, char, and a few pointer types, and it always compiles and always “succeeds” at runtime — but that does not mean it produces the value you expect. Casting a larger integer type down to a smaller one (like i32 as u8) truncates: Rust keeps only the lowest bits and discards the rest, which can silently wrap the value around. Casting a float to an integer truncates the fractional part toward zero, and if the float is outside the range the target integer can hold, the result saturates at the type’s minimum or maximum (and NaN becomes 0) rather than causing undefined behavior. as is fast and simple, but it is a blunt instrument: the compiler trusts that you already thought about whether data loss is acceptable.

The From and Into traits express infallible, meaning-preserving conversions, including between your own custom types. If a type implements From<A> for type B, the standard library automatically gives you the reverse direction: any A can call .into() to become a B. The convention is to always implement From, never Into directly, and let that blanket implementation do the rest. The standard library already implements From for many built-in widening conversions, such as i32 to i64 or &str to String.

TryFrom and TryInto are the fallible counterparts. Where From::from returns the target type directly, TryFrom::try_from returns a Result<T, E>, forcing you to handle the case where conversion cannot succeed — exactly the situation a blind as cast would paper over. Since the 2021 edition, both traits live in the prelude, so u8::try_from(some_i64) works without any import.

Strings are a special case: turning a &str into a number is not a primitive cast (Rust cannot reinterpret the bytes of “42” as an integer at the bit level — it has to actually read the text), so as does not apply at all. Any type implementing the FromStr trait, which includes every numeric type, gains a .parse::<T>() method returning Result<T, T::Err>. Going the other direction, any type implementing Display automatically gets .to_string(), turning it into an owned String.

Syntax

The table below summarizes the general form of each conversion tool.

Tool General Form Can Fail? Typical Use
as value as TargetType No (but may truncate, wrap, or saturate) Numeric casts, char <-> u32
From / Into TargetType::from(value) or value.into() No Widening numeric conversions, custom type conversions
TryFrom / TryInto TargetType::try_from(value) or value.try_into(), both returning Result<T, E> Yes Narrowing numeric conversions, validated custom conversions
.parse() text.parse::<T>() returning Result<T, T::Err> Yes String to number (or any FromStr type)
.to_string() value.to_string() No Number (or any Display type) to String

Examples

Example 1: Numeric Casting with as

This example shows four common uses of as: widening an integer to a float, truncating a float to an integer, narrowing an integer (which wraps), and converting between char and its Unicode code point.

fn main() {
    let integer: i32 = 65;
    let float: f64 = integer as f64;
    println!("i32 to f64: {} -> {}", integer, float);

    let pi: f64 = 3.9;
    let truncated: i32 = pi as i32;
    println!("f64 to i32 (truncates): {} -> {}", pi, truncated);

    let big: i32 = 300;
    let small: u8 = big as u8;
    println!("i32 to u8 (wraps): {} -> {}", big, small);

    let ch: char = 'A';
    let code: u32 = ch as u32;
    println!("char to u32: {} -> {}", ch, code);
}

Output:

i32 to f64: 65 -> 65
f64 to i32 (truncates): 3.9 -> 3
i32 to u8 (wraps): 300 -> 44
char to u32: A -> 65

The first cast widens i32 to f64, which cannot lose information, so 65 becomes 65.0 (Rust’s Display prints whole-number floats without a trailing .0). The second cast truncates 3.9 toward zero, producing 3as never rounds. The third cast is the dangerous one: 300 does not fit in a u8 (0–255), so Rust keeps only the lowest 8 bits, and 300 wraps around to 44. No warning, no error — this exact mistake is covered later. The last cast converts a char to its Unicode scalar value as a u32; 'A' is code point 65.

Example 2: Converting Between Strings and Numbers

Numbers become strings with .to_string(), and strings become numbers with .parse(). Because parsing can fail, .parse() always returns a Result.

fn main() {
    let number = 42;
    let number_str: String = number.to_string();
    println!("Number as string: {}", number_str);

    let input = "108";
    let parsed: i32 = input.parse().expect("not a valid number");
    println!("Parsed string to i32: {}", parsed);

    let bad_input = "not a number";
    match bad_input.parse::<i32>() {
        Ok(value) => println!("Parsed: {}", value),
        Err(e) => println!("Failed to parse input: {}", e),
    }

    let price: f64 = "19.99".parse().unwrap();
    println!("Price parsed as f64: {}", price);
}

Output:

Number as string: 42
Parsed string to i32: 108
Failed to parse input: invalid digit found in string
Price parsed as f64: 19.99

42.to_string() calls the Display implementation for i32 to build a new, owned String. Parsing “108” into an i32 works because every character is a digit, so .expect(...) unwraps the Ok value without panicking. Parsing “not a number” fails, and the match handles the Err case instead of crashing, printing the underlying ParseIntError‘s message. Note the turbofish ::<i32>() on the failing call — it tells .parse() which type to produce, since the compiler cannot infer it from a discarded match result on its own; in the earlier call, the type came from the let parsed: i32 annotation instead. The final line parses a decimal string directly into an f64.

Example 3: From, Into, and TryFrom for Custom and Checked Conversions

Custom types can participate in conversions too. Celsius and Fahrenheit are simple wrapper structs, and implementing From<Celsius> for Fahrenheit gives us both Fahrenheit::from(celsius_value) and celsius_value.into() for free. The same example shows TryFrom for a narrowing conversion that might fail.

struct Celsius(f64);
struct Fahrenheit(f64);

impl From<Celsius> for Fahrenheit {
    fn from(c: Celsius) -> Self {
        Fahrenheit(c.0 * 9.0 / 5.0 + 32.0)
    }
}

fn main() {
    let boiling = Celsius(100.0);
    let boiling_f: Fahrenheit = boiling.into();
    println!("100C in Fahrenheit: {}", boiling_f.0);

    let freezing = Celsius(0.0);
    let freezing_f = Fahrenheit::from(freezing);
    println!("0C in Fahrenheit: {}", freezing_f.0);

    let big_number: i64 = 200;
    match u8::try_from(big_number) {
        Ok(n) => println!("Converted to u8: {}", n),
        Err(e) => println!("Conversion failed: {}", e),
    }

    let too_big: i64 = 1000;
    match u8::try_from(too_big) {
        Ok(n) => println!("Converted to u8: {}", n),
        Err(e) => println!("Conversion failed: {}", e),
    }
}

Output:

100C in Fahrenheit: 212
0C in Fahrenheit: 32
Converted to u8: 200
Conversion failed: out of range integral type conversion attempted

boiling.into() works because the compiler sees the target type annotation Fahrenheit and looks for a matching From implementation — this moves boiling into the conversion, since Celsius does not implement Copy. Fahrenheit::from(freezing) calls the same logic more explicitly. For the TryFrom calls, 200 fits comfortably in a u8, so the conversion succeeds with Ok(200). 1000 does not fit, so u8::try_from(1000) returns Err with a TryFromIntError describing an out-of-range conversion — no wraparound, no silent data loss, just an explicit error you must handle.

How It Works Step by Step

For an as cast between integer types, the compiler emits a direct bit-level truncation or extension: narrowing keeps only the low-order bits of the source value, and widening zero-extends (unsigned sources) or sign-extends (signed sources) to fill the wider type. No range check happens at runtime, which is why narrowing can silently wrap.

For an as cast from a float to an integer, Rust checks the float against the target type’s range: values inside the range truncate toward zero, values above the maximum saturate to the type’s maximum, values below the minimum saturate to the type’s minimum, and NaN becomes 0. This saturating behavior has been guaranteed by the language since Rust 1.45, so it is safe to rely on in current stable Rust.

For value.into(), the compiler does not run special conversion machinery — it looks at the type annotation you are converting into, finds the matching impl From<SourceType> for TargetType, and calls its from function, which is ordinary Rust code. This is also why .into() sometimes needs an explicit type annotation: without one, the compiler cannot know which From implementation to pick.

For TryFrom, the idea is the same but try_from returns a Result. The standard library’s integer TryFrom implementations perform an explicit range check against the target type’s MIN and MAX, returning Err if the value falls outside that range instead of quietly truncating.

For .parse(), the compiler resolves T::from_str(&my_string) using the FromStr trait, where T is inferred from context or given with the turbofish. Each numeric type’s from_str walks the string’s bytes, validates that every character is a legal digit (plus sign and decimal point where relevant), and builds up the value — returning Err the moment it finds something that does not belong.

Common Mistakes

Mistake 1: Trying to Cast a String to a Number with as

It is tempting to reach for as everywhere, but it only works between primitive types — it has no idea how to turn the text “42” into the number 42. This fails to compile:

fn main() {
    let s = String::from("42");
    let n: i32 = s as i32;
    println!("{}", n);
}

Rustc rejects this with a non-primitive cast error, roughly:

error[E0605]: non-primitive cast: `String` as `i32`
note: an `as` expression can only be used to convert between primitive types, or to coerce to a specific trait object

The fix is .parse(), which actually interprets the characters:

fn main() {
    let s = String::from("42");
    let n: i32 = s.parse().expect("not a valid number");
    println!("{}", n);
}

Output:

42

as reinterprets bits it already has; it cannot manufacture a number from text. .parse() is a real algorithm that reads the string and builds the value, so it belongs to a different category of conversion (fallible, via FromStr) than the primitive-cast category as covers.

Mistake 2: Silent Truncation with as on Out-of-Range Values

The most dangerous thing about as is that it compiles cleanly and never panics, even when it throws away data. This program compiles and runs without complaint but produces a value nobody would expect:

fn main() {
    let user_input: i32 = 3000;
    let byte_value = user_input as u8;
    println!("Byte value: {}", byte_value);
}

Output:

Byte value: 184

3000 does not fit in a u8, so the cast wraps around to 184 with no indication anything went wrong. If user_input came from a file, a network request, or a user, this bug would be silent and easy to miss. The fix is TryFrom, which reports failure instead of hiding it:

fn main() {
    let user_input: i32 = 3000;
    match u8::try_from(user_input) {
        Ok(byte_value) => println!("Byte value: {}", byte_value),
        Err(_) => println!("Value {} does not fit in a u8", user_input),
    }
}

Output:

Value 3000 does not fit in a u8

Whenever a narrowing conversion’s input is not already known to be in range at compile time — especially anything derived from user input, file contents, or network data — prefer TryFrom/TryInto over as so the failure is a value you must handle, not a bug waiting to happen. A related trap: calling .parse() with no turbofish and no surrounding type annotation produces a “type annotations needed” compiler error, because parse is generic over its return type and Rust never guesses.

Best Practices

  • Reach for From/Into for conversions between your own types that can never fail; implement From and let .into() come for free.
  • Reach for TryFrom/TryInto for narrowing numeric conversions, or any custom conversion where invalid input is possible — handle the Result instead of assuming success.
  • Treat as as a low-level tool for cases where you already know the value fits (like char to u32), not as a general-purpose conversion operator.
  • Never call .unwrap() on .parse() for input you do not fully control; match on the Result or propagate it with ?.
  • When a function only needs to read a string, accept &str rather than String — it accepts both owned strings and literals and avoids forcing a conversion on the caller.
  • Prefer implementing From/TryFrom over ad-hoc conversion methods — it lets your type plug into any generic code already written against those traits.
  • When you do use as to narrow a type, note why the value is guaranteed to fit, since the compiler will not check that for you.

Practice Exercises

  1. Write a function that takes a &[f64] of Celsius readings and returns a Vec<f64> of Fahrenheit readings, printing the result. Then try a version that stores temperatures as i32 degrees Celsius, which will need as to produce f64 results.
  2. Write a function that takes a slice of string ages like ["25", "200", "oops", "40"] and returns a Vec<u8> containing only the ages that both parse successfully and fit in a u8, printing how many entries were skipped. Combine .parse::<i32>() with u8::try_from.
  3. Define Meters(f64) and Feet(f64) structs, implement From<Meters> for Feet (1 meter = 3.28084 feet), and convert a value with .into(). For 10 meters, expect roughly 32.8084 feet.

Summary

  • Rust never converts types implicitly — every conversion is a deliberate, visible operation in the code.
  • as performs primitive casts: fast and always “succeeds”, but can silently truncate, wrap, or saturate — use it only when you already know the value is in range.
  • From/Into model infallible conversions, including between custom types; implement From and get .into() for free.
  • TryFrom/TryInto model conversions that can fail, returning a Result you must handle — the right tool for narrowing or validating untrusted data.
  • .parse::<T>() converts a &str into any FromStr type, returning a Result; .to_string() converts any Display type into an owned String.
  • When in doubt, prefer the tool that makes failure explicit (TryFrom, checked .parse()) over one that hides it (as, .unwrap()).