Skip to content
rusthacks.dev
Architecture

Refactoring With the Compiler in Rust Safely and Fast

Change a type or signature and let rustc list every place that must change. A method for safe large refactors: make the change, follow errors, keep tests green at each step.

16 min read
Summarize in AI
Flat illustration for the post “Refactoring With the Compiler in Rust Safely and Fast”, composed around testing.

Refactoring in a dynamic language runs on courage and test coverage. You change a function signature and hope you found every caller. In Rust the compiler finds them for you, all of them, with a file and line number, before the program runs. That changes how you refactor: instead of making a change safely by making it small, you can make a sweeping change deliberately and let the compiler list the work. The technique is called compiler-driven development, and the loop is simple. Change a type. Read the errors. Fix them in order. Repeat until the build is clean.

This guide runs that loop on a real program through four refactors: a new type for money, a typed enum for discount codes, an error type to replace silent overflow, and a new struct field. Each step shows the exact messages the compiler produced. The code was built on Rust 1.99 (edition 2024) and everything printed is real. By the end, the behavior of one bug, a silent integer overflow that produced a wrong price in a release build, will have turned from a runtime accident into a compile-time requirement.

The program before

A shopping cart in 40 lines. It computes line totals, applies a discount code, and prints a price. It compiles, it runs and its output is right for the inputs it was tested with:

src/main.rsrust
#[derive(Debug, Clone)]
struct LineItem {
    sku: String,
    unit_price: u32, // cents
    quantity: u32,
}

fn line_total(item: &LineItem) -> u32 {
    item.unit_price * item.quantity
}

fn apply_discount(total: u32, code: &str) -> u32 {
    match code {
        "SAVE10" => total - total / 10,
        "HALF" => total / 2,
        _ => total,
    }
}

fn cart_total(items: &[LineItem], code: &str) -> u32 {
    let subtotal: u32 = items.iter().map(line_total).sum();
    apply_discount(subtotal, code)
}

fn describe(total: u32) -> String {
    format!("${}.{:02}", total / 100, total % 100)
}
Text
2 items, total $46.76
with HALF: $25.97

Three weaknesses are visible to a reader, and the compiler has noticed none of them. Money is a bare u32, so a price in cents, a quantity and an item count are interchangeable. The discount code is a free &str, so a typo like "SAVE1O" silently means no discount. And the multiplication in line_total can overflow. What it does on overflow depends on the build mode, which is the part worth seeing. Feeding in a pallet order of 2,000,000 units at 3999 cents each, in a debug build:

Text
thread 'main' panicked at src/main.rs:9:5:
attempt to multiply with overflow

and in a release build, the same program printed this without complaint:

Text
pallet: $37030327.04

The true total is 7,998,000,000 cents, about $79.98 million, and the release build's answer is less than half the true amount, because the result wrapped around 32 bits. A price bug that appears only in the optimized build and charges a customer the wrong amount is the kind of failure a refactor should remove at the root. Each step below removes one of the three weaknesses.

Step one: a type for money

Start with the change that touches the most code. Introduce a Cents newtype and use it for the unit price:

src/main.rsrust
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
struct Cents(u32);

#[derive(Debug, Clone)]
struct LineItem {
    sku: String,
    unit_price: Cents,
    quantity: u32,
}

Change nothing else, and ask the compiler what is now wrong:

Text
error[E0369]: cannot multiply `Cents` by `u32`
  --> src/main.rs:12:21
   |
12 |     item.unit_price * item.quantity
   |     --------------- ^ ------------- u32
   |     |
   |     Cents

error[E0308]: mismatched types
  --> src/main.rs:34:52
   |
34 |         LineItem { sku: "book".into(), unit_price: 3999, quantity: 1 },
   |                                                    ^^^^ expected `Cents`, found integer
   |
help: try wrapping the expression in `Cents`

error[E0308]: mismatched types
  --> src/main.rs:35:55
   |
35 |         LineItem { sku: "sticker".into(), unit_price: 299, quantity: 4 },
   |                                                       ^^^ expected `Cents`, found integer
   |
help: try wrapping the expression in `Cents`

error: could not compile `shop` (bin "shop") due to 3 previous errors

Three errors, three locations, and nothing else in the program needed attention. That is the whole inventory of places where a price was used as a raw number. The literal prices need wrapping in Cents(...), which the compiler suggests. The multiplication is the interesting one, and it is where the refactor pays off: the compiler has refused to multiply a Cents by a u32, because no one defined what that means. You get to decide, and the decision is the fix for the overflow bug. Give Cents an explicit, checked multiplication that returns an Option, so overflow becomes a value the caller must handle, not a wraparound:

src/main.rsrust
impl Cents {
    fn checked_mul(self, quantity: u32) -> Option<Cents> {
        self.0.checked_mul(quantity).map(Cents)
    }

    fn checked_add(self, other: Cents) -> Option<Cents> {
        self.0.checked_add(other.0).map(Cents)
    }
}

impl fmt::Display for Cents {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "${}.{:02}", self.0 / 100, self.0 % 100)
    }
}

The Display impl absorbs the old describe function, so formatting lives with the type. The new type has no Mul or Add operator implementations on purpose. Anything you did not implement is a mistake the compiler will catch at its next use. Deriving PartialOrd and Ord lets prices be compared, which is meaningful, and nothing lets a price be accidentally added to a quantity. Making illegal operations unrepresentable is what a newtype is for.

Step two: an enum for the discount code

The discount code is next. Replace the string with an enum whose variants are the valid codes, and parse the string once, at the edge:

src/main.rsrust
#[derive(Debug, Clone, Copy, PartialEq)]
enum DiscountCode {
    Save10,
    Half,
}

impl FromStr for DiscountCode {
    type Err = CartError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "SAVE10" => Ok(DiscountCode::Save10),
            "HALF" => Ok(DiscountCode::Half),
            other => Err(CartError::UnknownCode(other.to_string())),
        }
    }
}

Past the parsing boundary, no code handles a string at all. apply_discount matches on the enum, and the benefit of that shows up the day the business adds a third code. Add a FreeShipping variant and compile:

Text
error[E0004]: non-exhaustive patterns: `Some(DiscountCode::FreeShipping)` not covered
  --> src/main.rs:69:11
   |
69 |     match code {
   |     ^^^^ pattern `Some(DiscountCode::FreeShipping)` not covered
   |
   = note: the matched value is of type `Option<DiscountCode>`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown

The compiler found the one match that must now decide what free shipping means for the total. If the original code had used a string, the new code would have fallen into the _ => total arm and quietly done nothing, for the typo and the new feature alike. This is the most valuable property of enums in a refactor: adding a case produces a list of places that need to handle it. One discipline protects it. Do not write _ => arms on your own enums, because a wildcard silences exactly this error. List every variant, and the compiler will tell you when the list is incomplete.

Step three: errors instead of wrapping

Now return to the overflow. Cents::checked_mul returns Option, and the functions that use it must say what happens on None. Define an error type and make line_total and cart_total return Result:

src/main.rsrust
#[derive(Debug, PartialEq)]
enum CartError {
    Overflow,
    UnknownCode(String),
}

fn line_total(item: &LineItem) -> Result<Cents, CartError> {
    item.unit_price.checked_mul(item.quantity).ok_or(CartError::Overflow)
}

fn cart_total(items: &[LineItem], code: Option<DiscountCode>) -> Result<Cents, CartError> {
    let mut subtotal = Cents(0);
    for item in items {
        subtotal = subtotal.checked_add(line_total(item)?).ok_or(CartError::Overflow)?;
    }
    Ok(apply_discount(subtotal, code))
}

Changing a return type from u32 to Result<Cents, CartError> makes every caller handle it: main could no longer pass the result straight to a function expecting a number, and the old .sum() over line totals had to become an explicit loop with ?, because summing Result values into a Cents needs an operation that does not exist. Each of those was a compile error, in the same list-and-fix rhythm as step one. The loop is also where overflow gets its second check: adding two valid line totals can overflow even when each is fine alone, and checked_add covers it.

The whole program after the three refactors, in full, so you can see the finished shape:

src/main.rsrust
use std::fmt;
use std::str::FromStr;

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
struct Cents(u32);

impl Cents {
    fn checked_mul(self, quantity: u32) -> Option<Cents> {
        self.0.checked_mul(quantity).map(Cents)
    }

    fn checked_add(self, other: Cents) -> Option<Cents> {
        self.0.checked_add(other.0).map(Cents)
    }
}

impl fmt::Display for Cents {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "${}.{:02}", self.0 / 100, self.0 % 100)
    }
}

#[derive(Debug, PartialEq)]
enum CartError {
    Overflow,
    UnknownCode(String),
}

impl fmt::Display for CartError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            CartError::Overflow => f.write_str("the cart total is too large"),
            CartError::UnknownCode(code) => write!(f, "unknown discount code {code:?}"),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq)]
enum DiscountCode {
    Save10,
    Half,
}

impl FromStr for DiscountCode {
    type Err = CartError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "SAVE10" => Ok(DiscountCode::Save10),
            "HALF" => Ok(DiscountCode::Half),
            other => Err(CartError::UnknownCode(other.to_string())),
        }
    }
}

#[derive(Debug, Clone)]
struct LineItem {
    sku: String,
    unit_price: Cents,
    quantity: u32,
}

fn line_total(item: &LineItem) -> Result<Cents, CartError> {
    item.unit_price.checked_mul(item.quantity).ok_or(CartError::Overflow)
}

fn apply_discount(total: Cents, code: Option<DiscountCode>) -> Cents {
    match code {
        None => total,
        Some(DiscountCode::Save10) => Cents(total.0 - total.0 / 10),
        Some(DiscountCode::Half) => Cents(total.0 / 2),
    }
}

fn cart_total(items: &[LineItem], code: Option<DiscountCode>) -> Result<Cents, CartError> {
    let mut subtotal = Cents(0);
    for item in items {
        subtotal = subtotal.checked_add(line_total(item)?).ok_or(CartError::Overflow)?;
    }
    Ok(apply_discount(subtotal, code))
}

fn run() -> Result<(), CartError> {
    let items = vec![
        LineItem { sku: "book".into(), unit_price: Cents(3999), quantity: 1 },
        LineItem { sku: "sticker".into(), unit_price: Cents(299), quantity: 4 },
    ];
    let code: DiscountCode = "SAVE10".parse()?;
    println!("{} items, total {}", items.len(), cart_total(&items, Some(code))?);
    println!("with HALF: {}", cart_total(&items, Some("HALF".parse()?))?);

    let huge = vec![LineItem { sku: "pallet".into(), unit_price: Cents(3999), quantity: 2_000_000 }];
    match cart_total(&huge, None) {
        Err(e) => println!("pallet order: {e}"),
        Ok(t) => println!("pallet order: {t}"),
    }

    let _ = "FREEBIE".parse::<DiscountCode>().map_err(|e| println!("{e}"));
    Ok(())
}

fn main() {
    if let Err(e) = run() {
        eprintln!("error: {e}");
        std::process::exit(1);
    }
}
Text
2 items, total $46.76
with HALF: $25.97
pallet order: the cart total is too large
unknown discount code "FREEBIE"

The first two lines match the original output exactly, which is the check that the refactor preserved behavior for valid input. The third line is the same pallet order that wrapped to $37 million in the release build, now reported as an error in debug and release alike, with no change in behavior between the two. The fourth is a typo in a discount code, which used to mean "no discount" and is now a message to the user. Two bug classes closed, and neither needed a new test to find it; both were made unrepresentable.

Step four: adding a field

The last refactor is the one every codebase does a hundred times: a struct needs a new field. Add taxable: bool to LineItem:

Text
error[E0063]: missing field `taxable` in initializer of `LineItem`
  --> src/main.rs:86:9
   |
86 |         LineItem { sku: "book".into(), unit_price: Cents(3999), quantity: 1 },
   |         ^^^^^^^^ missing `taxable`

error[E0063]: missing field `taxable` in initializer of `LineItem`
  --> src/main.rs:87:9
   |
87 |         LineItem { sku: "sticker".into(), unit_price: Cents(299), quantity: 4 },
   |         ^^^^^^^^ missing `taxable`

error[E0063]: missing field `taxable` in initializer of `LineItem`
  --> src/main.rs:93:21
   |
93 |     let huge = vec![LineItem { sku: "pallet".into(), unit_price: Cents(3999), quantity: 2_000_000 }];
   |                     ^^^^^^^^ missing `taxable`

Every construction site, three of them, in a list. In a language with default field values or keyword-argument constructors, the new field would silently become null or 0 at each of those sites, and whether that is right would be a question for the reviewer. Here each site demands a decision. If the field has a sensible default, implement Default and use ..Default::default() in constructors, which trades the explicit list for convenience; if it does not, the errors force you to look at each place. Choose consciously, and note that patterns that destructure the struct (let LineItem { sku, .. } = item) keep compiling when fields are added if they use .., while ones that name every field break, which is sometimes what you want.

Step five: changing ownership

Of all refactors, "stop copying this string" is the one most likely to teach you something. The LineItem owns its sku as a String, so building one allocates. If the SKUs already live somewhere else, borrowing them avoids the copy. Change the field to &str and the compiler stops at the struct definition itself:

Text
error[E0106]: missing lifetime specifier
 --> src/main.rs:2:10
  |
2 |     sku: &str,
  |          ^ expected named lifetime parameter
  |
help: consider introducing a named lifetime parameter
  |
1 ~ struct LineItem<'a> {
2 ~     sku: &'a str,

Take the suggestion: LineItem<'a> with sku: &'a str. That fixes the definition and moves the error to the places that build items. A function that creates a String locally and puts a reference to it into the item returned from the function has made a dangling reference, and the compiler says so:

Text
error[E0515]: cannot return value referencing local variable `sku`
 --> src/main.rs:8:5
  |
8 |     LineItem { sku: &sku, quantity: 1 }
  |     ^^^^^^^^^^^^^^^^----^^^^^^^^^^^^^^^
  |     |               |
  |     |               `sku` is borrowed here
  |     returns a value referencing data owned by the current function

This is the lifetime system doing its job as a refactoring tool. Switching to borrowed fields forced you to decide where the data lives. If the strings come from a long-lived catalog, the borrow is right and LineItem<'a> is tied to the catalog's lifetime. If the item is built from a request and outlives the request body, it has to own its data. The error does not give you the answer, but it refuses to let you ship the wrong one. A practical rule from this: borrow in functions that process data and return, and own in types that get stored, queued, sent between threads or returned from long-lived services. Lifetimes in a public struct are infectious, every user must name them, so default to owned fields until profiling shows the copies matter.

Step six: extracting a trait

The last common refactor is generalizing. The cart looks up prices in a table today, and tomorrow a pricing service will supply them. Extract a trait for the lookup, make the function generic over it, and implement the trait for the existing source. Everything that used the old function still compiles, because FlatPrices implements PriceSource. A type that does not implement it produces a message that names both the missing implementation and the types that have one:

Text
error[E0277]: the trait bound `Catalog: PriceSource` is not satisfied
  --> src/main.rs:24:26
   |
24 |     println!("{}", total(&catalog, &["a"]));
   |                    ----- ^^^^^^^^ unsatisfied trait bound
   |                    |
   |                    required by a bound introduced by this call
   |
help: the trait `PriceSource` is not implemented for `Catalog`
  --> src/main.rs:13:1
   |
13 | struct Catalog {
   | ^^^^^^^^^^^^^^
help: the trait `PriceSource` is implemented for `FlatPrices`
  --> src/main.rs:7:1

The error points at the call, not at the function, and the second help line lists the implementors, which is usually enough to see whether you forgot an impl or passed the wrong value. After the extraction the cart can be tested with a three-line fake source that returns fixed prices, which is the practical payoff of the refactor: a function that took a concrete database handle now takes anything that answers price_of. The trade-off, as ever with generics, is monomorphization: each source type used with total gets its own compiled copy. For a handful of types that is irrelevant, and when it matters you switch to &dyn PriceSource and accept one indirect call per lookup.

Habits that make the loop work

The technique has a rhythm, and a few habits make it smooth.

Fix errors from the top. One change often causes a cascade, and the later errors disappear when the first is fixed. Read the first error, fix it, and rerun cargo check (which is much faster than cargo build) before reading the second.

Prefer a change that makes many callers wrong over one that makes them subtly different. Changing a type is loud: every use fails to compile. Changing the behavior of a function while keeping its signature is silent, and tests are your only protection. When you can express the change in the types, do.

Keep commits small and the build green between them. You can still do the sweeping change in one commit, but do it on a branch where cargo check is the loop, and run the tests when the errors are gone, not before.

Do not paper over errors with escape hatches. A cast, a .unwrap(), an as conversion or a _ => arm turns a compile error back into a runtime question. Each one is sometimes right and each should be a decision with a comment.

Lean on the lints. cargo clippy suggests the idiomatic form of many of the things above, and the unused_must_use lint is on by default, so ignoring a Result you meant to handle produces a warning.

Refactoring a public API without breaking users

Everything so far assumed you own every caller. When you publish a library, or when another team's code calls yours, a change that makes callers fail to compile is a breaking change, and the compiler's help becomes the problem. The tool for gradual migration is #[deprecated]: keep the old function, mark it, and let callers upgrade at their own pace.

src/lib.rsrust
impl Cart {
    #[deprecated(since = "0.4.0", note = "use `total_cents`, which returns a `Cents` newtype")]
    pub fn total(&self) -> u32 {
        self.total
    }

    pub fn total_cents(&self) -> u32 {
        self.total
    }
}
Text
warning: use of deprecated method `Cart::total`: use `total_cents`, which returns a `Cents` newtype
  --> src/main.rs:18:25
   |
18 |     println!("{}", cart.total());
   |                         ^^^^^
   |
   = note: `#[warn(deprecated)]` on by default

The old method still works and every call site now carries a warning with the replacement named in the message. Callers who deny warnings in CI see it as an error, which is how they find out. The sequence for a clean migration is three releases: introduce the new API alongside the old, mark the old one deprecated with a message naming the replacement, and remove it in the next breaking version. For a type change like the Cents newtype, add a From<u32> for Cents and an impl From<Cents> for u32 during the transition, so callers can convert at the boundary and migrate inward at their pace. Mark new enum variants and structs #[non_exhaustive] from the start if you expect them to grow, so adding a variant is not itself a breaking change, which would have changed the story in step two for downstream users.

What the editor can do for you

Not every refactor needs to start with a compile error. rust-analyzer performs the mechanical ones directly: rename a symbol across the workspace, extract a function or a variable, inline a function, change a function signature and fix its callers, convert between if let and match, and generate missing match arms for an enum. Use them for the changes where the edit is the same everywhere. The compiler-driven loop is for the changes where each site needs a judgment: what should this caller do with a Result, which discount applies for a new code, what is the right default for a new field. A rename is mechanical; a new error case is a design decision at every call. Knowing which kind you are doing tells you which tool to reach for.

Where the compiler cannot help

The compiler verifies types, not meaning. It will not tell you that Cents(a).0 - Cents(b).0 underflows when b > a; the tool for that is checked_sub and a decision about what to return. It will not notice that you swapped two arguments of the same type, which is the argument for newtypes over bare integers. It cannot tell you that a refactor changed the output of a function that still returns a String. And it does not run your program: behavior preserved at the type level can still change at run time. This is why the first two output lines in step three matter. Keep a few end-to-end checks, or snapshot tests, that run before and after, so "the build is green" is not mistaken for "nothing changed".

For the larger structural moves that follow naturally from this kind of work, such as lifting a type into its own crate, see organizing Rust projects as they grow into workspaces, and for the idioms behind the types used here, the newtype pattern.

An order of operations you can reuse

When a refactor is large, write the sequence down before you start. First, get the build and the tests green and commit; every refactor begins from a known-good state so that a regression is always yours and always recent. Second, make the type change that carries the intent, the newtype, the enum, the new return type, and nothing else. Third, drive cargo check to zero errors, taking them from the top and committing nothing until it is clean. Fourth, run the tests and compare the program's output on a few real inputs with the output from before the change. Fifth, remove the temporary scaffolding, the From impls and the deprecated wrappers, in a separate commit so the history shows the move and the cleanup apart. A refactor shaped like this is boring to review and cheap to revert, which is the best compliment a refactor can earn.

Also inTesting
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.