scipio avatar

Learn Rust Series (#21) - From, Into, TryFrom & Idiomatic Conversions

scipio

Published: 08 Aug 2026 › Updated: 08 Aug 2026Learn Rust Series (#21) - From, Into, TryFrom & Idiomatic Conversions

Learn Rust Series (#21) - From, Into, TryFrom & Idiomatic Conversions

Learn Rust Series (#21) - From, Into, TryFrom & Idiomatic Conversions

rust-banner.png

What will I learn

  • You will learn how From and Into express infallible conversions, and why implementing From hands you Into for free;
  • how to write ergonomic APIs that accept impl Into<T> so callers can pass several types without ceremony;
  • how TryFrom and TryInto handle conversions that can fail, returning a Result with your own error type;
  • how the ? operator quietly uses From to convert one error type into another;
  • the single idiomatic rule that ties it all together: implement From, and let everything else follow.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Rust toolchain (via rustup, from rustup.rs);
  • The previous twenty episodes, especially error handling from episode 6 and traits from episode 8;
  • The ambition to learn systems programming from the ground up.

Difficulty

  • Beginner

Curriculum (of the Learn Rust Series):

Learn Rust Series (#21) - From, Into, TryFrom & Idiomatic Conversions

Converting one type into another is one of those things you do a hundred times a day without noticing: a &str becomes a String, an i32 widens into an i64, a raw string from a config file becomes a validated, structured value. In most languages this is a mess of ad-hoc helpers -- toString() here, a parseInt() there, a constructor that takes six overloads somewhere else. Rust does something quietly brilliant instead: it standardises conversion behind a tiny family of four traits, From, Into, TryFrom, and TryInto, so that every conversion in the entire ecosystem looks and reads the same way. Learn the small set of rules in this episode and you will write cleaner constructors, more flexible function signatures, and error handling that composes almost by itself ;-)

Having said that, before we open the new topic we clear last episode's homework, as always.

Solutions to Episode 20 Exercises

Episode 20 was Drop and RAII: how the compiler calls drop(&mut self) automatically at the end of a value's scope, why locals drop in reverse of declaration while struct fields drop top-to-bottom, and why guard types are the whole reason the trait exists. There were three exercises, and here is each one with full code you can paste and run.

Exercise 1 asked you to create three Noisy values in main, wrap the middle one in its own inner { ... } block, and predict the drop order before running it:

struct Noisy(&'static str);

impl Drop for Noisy {
    fn drop(&mut self) { println!("dropping {}", self.0); }
}

fn main() {
    let _first = Noisy("first");
    {
        let _middle = Noisy("middle");
        println!("inside the inner block");
    } // _middle drops HERE, at the inner brace
    let _last = Noisy("last");
    println!("end of main reached");
    // now _last drops, then _first (reverse order)
}

The output is "inside the inner block", "dropping middle", "end of main reached", "dropping last", "dropping first". The key insight is that the inner block bounds _middle's life to that scope, so it is gone before _last is even born, while _first and _last fall away at the closing brace of main in reverse of declaration order -- last in, first out.

Exercise 2 wanted a FileGuard struct that prints "opening" in a new constructor and "closing" in its Drop impl, so the two messages bracket your work exactly:

struct FileGuard(String);

impl FileGuard {
    fn open(name: &str) -> FileGuard {
        println!("opening {name}");
        FileGuard(name.to_string())
    }
}

impl Drop for FileGuard {
    fn drop(&mut self) { println!("closing {}", self.0); }
}

fn main() {
    let _f = FileGuard::open("data.txt");
    println!("working with the file");
    // _f drops at the end of main: "closing data.txt"
}

The insight here is that acquisition happens in the constructor and release happens in drop, so the guard cannot forget to close: whatever happens between the two println!s, the closing message is guaranteed to fire when _f goes out of scope. This is exactly the pattern a real File handle or MutexGuard uses under the hood.

Exercise 3 asked for a struct with three fields, each a small type with its own Drop impl, plus a Drop impl on the struct itself, to confirm the ordering:

struct Field(&'static str);
impl Drop for Field {
    fn drop(&mut self) { println!("dropping field {}", self.0); }
}

struct Bundle { a: Field, b: Field, c: Field }
impl Drop for Bundle {
    fn drop(&mut self) { println!("dropping Bundle itself"); }
}

fn main() {
    let _bundle = Bundle { a: Field("a"), b: Field("b"), c: Field("c") };
    // output: "dropping Bundle itself", then field a, then b, then c
}

The struct's own drop body runs first, and only then do the fields drop in declaration order, top to bottom. That is the mirror image of how local variables behave, and both rules exist for the same reason: nothing is ever torn down while something that might depend on it is still alive. Right, homework cleared -- on to conversions ;-)

From: the one trait you actually implement

The heart of the whole family is From. The declaration impl From<A> for B says, in plain English, "you can build a B out of an A", and it is the trait you actually sit down and write:

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

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

fn main() {
    let f = Fahrenheit::from(Celsius(100.0));
    println!("{}", f.0); // 212
}

From is specifically for conversions that cannot fail. Every possible Celsius value maps to exactly one valid Fahrenheit, so there is no error case to worry about, no input that has to be rejected. That "totality" is the entire contract of From: if any input could fail to convert, you have reached for the wrong trait (and we will meet the right one shortly). The standard library implements From for hundreds of type pairs, which is precisely why String::from("hi") and i64::from(5i32) just work without you ever thinking about them -- someone wrote that one small impl, once, and the whole language benefits.

Notice also which direction the impl reads. We wrote From<Celsius> for Fahrenheit, so the type doing the converting is Fahrenheit (the target), and Celsius is the source that gets consumed. This trips people up at first, so read it as "Fahrenheit knows how to be built from a Celsius". The source is moved into from and the target comes out.

Into comes along for free

Here is the payoff, and it is a lovely one. The standard library contains a single blanket implementation that reads, roughly, "for any T and U where U: From<T>, automatically provide Into<U> for T". You do not have to do anything to trigger it. Write one From impl and you get the matching Into handed to you at no cost:

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

fn main() {
    let a = Fahrenheit::from(Celsius(0.0));   // via the From we wrote
    let b: Fahrenheit = Celsius(37.0).into(); // via the free Into
    println!("{} {}", a.0, b.0); // 32 98.6
}

Both lines do the identical work; they just spell it differently. Fahrenheit::from(Celsius(0.0)) calls our impl directly, while Celsius(37.0).into() calls the auto-derived Into::into, which turns straight around and calls the very same from under the hood. The rule that falls out of this is one of the most important idioms in the language, so I will put it in bold: always implement From, never implement Into by hand, and you get both directions automatically. Implementing Into directly is not only redundant, it can actually block the blanket impl and cause confusing errors, so just do not.

There is one small wrinkle worth knowing. Because .into() figures out its target from context, you usually have to tell the compiler what you want, which is why line b has an explicit : Fahrenheit annotation. With from the target is spelled out in Fahrenheit::from(...), so no annotation is needed. That is really the only day-to-day difference between the two.

Once you internalise this, a huge amount of the standard library's "it just converts" magic stops being magic and becomes visible as ordinary From impls you could have written yourself:

fn main() {
    let s = String::from("hi");                   // From for String
    let n = i64::from(42i32);                      // From for i64 (widening)
    let bytes = Vec::<u8>::from("hi".as_bytes());  // From for Vec
    let boxed: Box<i32> = Box::from(7);            // From for Box
    println!("{s} {n} {bytes:?} {boxed}");         // hi 42 [104, 105] 7
}

Every one of those is just a small From impl in the standard library. Nothing more clever than what we wrote for Celsius. The consistency is the point: once you know the pattern, you can predict where conversions exist and how to spell them, across code you have never seen.

Into as a flexible parameter type

So if you should never implement Into, why does it exist as a separate trait at all? Because it is a wonderful thing to ask for. When a function takes impl Into<T> as a parameter, it will accept any type that knows how to convert into T, and it does that conversion inside the function body:

fn greet(name: impl Into<String>) {
    let name = name.into(); // now definitely a String
    println!("hello, {name}");
}

fn main() {
    greet("Rust");                 // &str  -> String
    greet(String::from("world"));  // String -> String (a cheap no-op)
    greet(format!("user {}", 7));  // String -> String
}

This is the everyday reason Into earns its keep. The caller never has to write .to_string() or .into() at the call site; they just pass whatever they have, and the function absorbs the conversion. You see this constantly in constructors and builders: a Config::new(name: impl Into<String>) lets a user pass a &str literal, an owned String, or the result of a format!, all without ceremony. The cost is essentially nothing, because String -> String compiles down to a move, not a copy.

There is a subtle judgement call here, though, and I want to be honest about it. impl Into<String> is generic, which means the compiler stamps out a separate copy of greet for each concrete argument type (monomorphisation, exactly as we discussed back in episode 16 on static versus dynamic dispatch). For a small function that is completely fine and idiomatic. For a very large function called with many different types you might prefer a plain &str parameter to keep code size down. Most of the time, reach for impl Into<String> on the "front door" functions of your API where ergonomics matter most, and do not overthink it.

TryFrom: conversions that are allowed to fail

Now for the other half of the family. A great many conversions are not total: some inputs simply cannot become the target type. Parsing "42" into an i32 works, parsing "banana" does not. Turning an i64 into a "number that must be even" works for 4 but not for 5. For every conversion that can be rejected, From is the wrong tool, and TryFrom is the right one. It looks almost identical to From, except it has an associated Error type and returns a Result:

struct EvenNumber(i64);

impl TryFrom<i64> for EvenNumber {
    type Error = String;
    fn try_from(value: i64) -> Result<EvenNumber, Self::Error> {
        if value % 2 == 0 {
            Ok(EvenNumber(value))
        } else {
            Err(format!("{value} is odd"))
        }
    }
}

fn main() {
    let ok = EvenNumber::try_from(4);
    println!("{}", ok.is_ok()); // true

    let r: Result<EvenNumber, _> = 5i64.try_into(); // free TryInto
    println!("{}", r.is_err()); // true
}

The parallel to From is exact, and it goes all the way down. Just as From gives you Into for free through a blanket impl, TryFrom gives you TryInto for free through the very same mechanism, which is why 5i64.try_into() works without us writing a single line of TryInto. The type Error = String line is the associated type we studied in episode 17: each TryFrom impl names the error it produces, and here I have used a plain String for readability, though in real code you would usually use a proper error enum (as we built in episode 6). The mental model is clean: use From/Into when every input converts, and TryFrom/TryInto the moment some inputs must be turned away.

A concrete, real-world flavour of this: the standard library uses TryFrom for the narrowing integer conversions that From refuses to provide. You can go from i32 to i64 with From because it always fits, but going the other way can overflow, so that direction is TryFrom:

fn main() {
    let big: i64 = 300;
    let small: Result<u8, _> = u8::try_from(big);
    println!("{:?}", small); // Err(...) because 300 does not fit in a u8

    let fits: Result<u8, _> = u8::try_from(200i64);
    println!("{:?}", fits); // Ok(200)
}

This is the language being principled. A conversion that can lose data is never silent in Rust; it is spelled try_from, it returns a Result, and you are forced to acknowledge the failure case. Coming from Python or C, where an out-of-range assignment might wrap, truncate, or raise at some distant point, this up-front honesty is one of the things that makes Rust code so much easier to trust.

Why the ? operator is secretly built on From

There is a deeper reason the whole ecosystem standardises on From, and it is my favourite part of this episode: the ? operator uses From to convert error types automatically. Cast your mind back to episode 6 on error handling. When you write something? inside a function that returns Result<_, E>, and something is a Result<_, F> with a different error type F, Rust does not simply give up. It looks for a From<F> for E impl and, if it finds one, calls From::from to convert the error before returning it. The ? is doing an invisible From::from on the error path:

use std::num::ParseIntError;

#[derive(Debug)]
enum AppError {
    Parse(ParseIntError),
}

impl From<ParseIntError> for AppError {
    fn from(e: ParseIntError) -> AppError {
        AppError::Parse(e)
    }
}

fn parse_sum(a: &str, b: &str) -> Result<i32, AppError> {
    let x: i32 = a.parse()?; // parse() yields ParseIntError, ? converts it to AppError
    let y: i32 = b.parse()?;
    Ok(x + y)
}

fn main() {
    println!("{:?}", parse_sum("2", "3"));    // Ok(5)
    println!("{:?}", parse_sum("2", "oops")); // Err(Parse(ParseIntError { .. }))
}

Look closely at what those two ? operators pull off. Each a.parse() returns a Result<i32, ParseIntError>, but our function promises to return Result<i32, AppError>. Those error types do not match. In a language without this mechanism you would be writing .map_err(AppError::Parse)? on every single fallible line, which is exactly the kind of boilerplate that makes error handling miserable. But because we implemented From<ParseIntError> for AppError once, the ? operator finds it and does the conversion silently, everywhere, for free. Write the From impl one time, and every ? in your codebase that needs it becomes ergonomic.

This is the machinery that makes hand-rolled error enums pleasant to use, and it is the reason a whole category of popular error-handling crates exist mainly to generate these From impls for you so you do not have to type them out by hand. We will meet that style of tooling later in the phase, when we look at deriving traits automatically. For now, the important thing is the principle: ? is not magic, it is From::from on the error path, and that is a big part of why "implement From" is such load-bearing advice.

How this looks from Python and Go

Since many of you come to this series from Python (as I did, having taught it for years), it helps to see where other languages land on the same problem. Python has no standard conversion protocol at all: you get str(), int(), float(), a pile of constructors, and per-class __int__ or __str__ dunder methods that are only loosely coordinated. There is no single trait that says "this converts to that", and certainly nothing like the ?-uses-From trick, because Python signals failure with exceptions raised at call time rather than a Result you convert. It works, but the conversions are scattered and each library invents its own conventions.

Go is closer in spirit but still more manual. Conversions between numeric types are a built-in syntax (int64(x)), custom conversions are just ordinary constructor functions you name yourself (NewThing(...)), and fallible ones return the idiomatic (value, error) pair that you check by hand:

package main

import (
    "fmt"
    "strconv"
)

func main() {
    // fallible conversion: string -> int, checked by hand
    n, err := strconv.Atoi("42")
    if err != nil {
        fmt.Println("bad input")
        return
    }
    fmt.Println(n + 1) // 43
}

That n, err := ...; if err != nil dance is Go's TryFrom, done longhand at every call site. It is explicit and clear, but there is no shared From/Into vocabulary, no free reverse direction, and no equivalent of ? automatically threading error conversions for you. Rust's contribution is not that conversion is possible (it is possible everywhere) but that it is standardised: one small family of traits, one blanket-impl trick for the free direction, and one operator that leans on all of it to make error handling compose. Three languages, three philosophies -- Python scatters it, Go spells it out by hand, and Rust folds it into the type system so the common cases nearly write themselves.

The one rule to remember

If you take a single sentence away from this episode, make it this: implement From (or TryFrom when it can fail), and never implement Into or TryInto yourself. Everything good flows from that one decision. You get the reverse direction for free. You get impl Into<T> parameters that make your APIs a pleasure to call. You get ?-driven error conversion that erases boilerplate. And you get code that any other Rust programmer can read instantly, because it uses the exact same conversion vocabulary as the standard library and every crate on crates.io. It is a rare case where the idiomatic path is also the least work, and I love it for that ;-)

What did we actually learn?

  • From<A> for B is the trait you implement to say "a B can be built from an A", and it is strictly for conversions that cannot fail; the source is consumed and the target comes out.
  • Implementing From gives you Into automatically through a blanket impl, so the rule is always write From, never write Into by hand -- and remember .into() usually needs a type annotation because it infers its target from context.
  • impl Into<T> is a superb parameter type: it lets callers pass anything convertible to T (a &str where a String is wanted, say) without writing .to_string() at the call site.
  • TryFrom is the fallible sibling: it carries an associated Error type, returns a Result, gives you TryInto for free, and is what the standard library uses for narrowing conversions that could overflow.
  • The ? operator calls From::from on the error path, so implementing From<F> for YourError once makes every ? that produces an F convert cleanly into YourError, which is the backbone of ergonomic custom error types.

The thread running through this stretch of the series has been Rust handing you one small, sharp, single-purpose trait at a time -- associated types, operators, deref, drop, and now conversions -- and showing how each one clicks into the others. Conversions in particular sit right next to the deriving machinery, because so many of these little impls are so mechanical that the compiler can write them for you if you ask nicely. That is exactly where we go next, but one thing at a time ;-)

Exercises

Three exercises, gentle to chewier as always. Full solutions open the next episode, so have a real go first, because typing this stuff yourself is where it actually sticks.

  1. Implement From<(f64, f64)> for a Point { x: f64, y: f64 } struct so that you can write let p: Point = (1.0, 2.0).into();, then print p.x and p.y to confirm the tuple's two fields landed in the right places. Remember: implement From, and Into comes free.
  2. Write a constructor fn new(title: impl Into<String>) -> Article for an Article struct with a single title: String field, then call it once with a &str literal and once with an owned String, and print both titles to prove the same constructor accepts both.
  3. Implement TryFrom<i32> for a Percentage(i32) that returns Ok only for values in 0..=100 and an Err(String) otherwise, then test it with 50, -5, and 150 using both Percentage::try_from(...) and the free .try_into() form, printing whether each result is Ok or Err.

Veel plezier met converteren, en tot de volgende keer! ;-)

scipio@scipio

Leave Learn Rust Series (#21) - From, Into, TryFrom & Idiomatic Conversions to:

Written by

Does it matter who's right, or who's left?

Read more #stem posts


Best Posts From scipio

We have not curated any of scipio's posts yet. But you can encourage our curation team to review posts by visiting them regularly and by referring other readers. Because we give priority to frequently read content.

More Posts From scipio