Skip to content

The Rust Engineering Handbook / Chapter 5

The Compiler as a Design Partner

Use Rust diagnostics to test design hypotheses, compare ownership repairs, and avoid trial-and-error fixes.

A rejection is the next trace

Chapter 4 gave us a way to slow down a dense expression: identify its places and values, order its effects, and mark every point where control or ownership changes. A compiler rejection asks for the same discipline. The difference is that one requested capability relationship cannot exist.

A diagnostic is a partial trace assembled by the compiler: one span shows where a capability begins, another shows the incompatible request, and a later use explains why the first capability must still be active. The engineer’s job is to recover that relationship before changing the program.

Here is the first relationship to recover:

struct Store {
    names: Vec<String>,
    audits: usize,
}

impl Store {
    fn audit(&mut self, subject: &str) {
        self.audits += usize::from(!subject.is_empty());
    }
}

fn rejected(store: &mut Store) {
    let subject = &store.names[0];
    store.audit(subject);
}

The compiler reports E0502. A shared borrow derived from store.names remains in use while audit asks for exclusive mutable access to the whole Store. That sentence—not the suggested edit—is the beginning of the design review.

Changing &str to String, adding .clone(), or wrapping state in Arc<Mutex<_>> may make a diagnostic disappear. Those edits also select allocation, snapshot, lifecycle, and synchronization semantics. The compiler did not request that architecture. It established that the original capability relationship is invalid.

Rust diagnostics are unusually useful because they point toward violated type, lifetime, and aliasing relationships. Treat each diagnostic as evidence against a design hypothesis: state the relationship, predict what a repair changes, test it with the smallest faithful reproduction, then restore production constraints before accepting it. This is the last step in Part I’s reading method: trace accepted code and rejected code with the same care.

Read the diagnostic in layers

The compiler reports E0502: store cannot be mutably borrowed for the method call while an immutable borrow derived from store.names remains live and is used as the argument. Read such output in layers:

  1. Error code and headline. The code is a stable search handle; the headline summarizes the conflicting operation.
  2. Primary span. This is where the rejected use becomes unavoidable—here, the mutable receiver borrow for audit.
  3. Secondary span. This shows where the conflicting immutable borrow begins and where it is later used.
  4. Notes. Notes connect spans into a relationship: one borrow remains active across another incompatible borrow.
  5. Help. Suggested syntax may shorten or change the borrow. It cannot know whether cloning, moving, splitting state, or changing the method contract matches the product.

Read the spans as a time-and-capability argument: a shared reference into store.names must remain valid through the call, while the call asks for exclusive mutable access to the whole Store. The diagnostic gives you the reading order, not the decision. A help line is a candidate edit, not a verdict about allocation, lifecycle, API stability, latency, or maintainability.

Turn the error into a falsifiable statement

Before editing, state four facts:

  • Intended invariant: auditing should count a subject selected from the store.
  • Current ownership: the store owns all names; subject borrows one name.
  • Rejected relationship: a live borrow into one field overlaps a mutable borrow of the entire store.
  • Unanswered design question: does auditing need the subject text, only a derived fact, ownership of the subject, or mutation confined to another field?

That last question produces competing hypotheses:

  1. The call needs only a small derived value; end the field borrow before mutation.
  2. The operation is a lifecycle boundary; move the subject or the store into an owning operation.
  3. A stable snapshot is intentional; clone explicitly and accept its cost and freshness semantics.

Now the compiler can test each hypothesis. Without this step, every edit is merely “something that might compile.”

Repair one: borrow at the leaf

If the audit needs only a property, compute it while borrowing the field, then mutate the disjoint state after that borrow ends:

fn borrow_at_leaf(store: &mut Store) {
    let subject_is_nonempty = !store.names[0].is_empty();
    store.audits += usize::from(subject_is_nonempty);
}

This design has the least ownership change. A bool carrying the exact predicate used by audit is copied out, the borrow of the name no longer needs to remain active, and the counter update follows with the same behavior. It allocates nothing.

An alternative is to split the data into distinct fields through a helper that borrows them separately or to change audit so it mutates only the counter rather than taking &mut Store. Field-disjoint borrowing works when the API exposes the actual mutation domain.

This family fits when the operation needs a small derived fact, the store should retain the subject, and the type can expose narrower mutation without violating encapsulation. It fails when the operation must retain the full subject or when deriving the fact early changes its meaning.

Repair two: move ownership at a lifecycle boundary

If the operation consumes a subject or store stage, ownership transfer may express the real lifecycle:

fn move_ownership(mut store: Store) -> Store {
    let subject = store.names.remove(0);
    store.audit(&subject);
    store
}

Removing the string moves it out of the vector. subject is now independent owned storage, so borrowing it during a mutable store call does not borrow through the store. Returning the store makes the new owner flow explicit.

This exact implementation shifts vector elements after index zero and therefore may be expensive for large vectors. A queue, swap_remove, stable handle, or redesigned command object may fit better depending on ordering requirements. Ownership correctness does not prove data-structure fitness.

Transfer fits terminal or stage-oriented processing, especially when one component should own retries, cleanup, and completion. It is a poor repair when callers need ongoing access or when moving the whole store merely works around an overly broad method receiver.

Repair three: clone an intentional snapshot

Cloning can be correct:

fn clone_snapshot(store: &mut Store) {
    let subject = store.names[0].clone();
    store.audit(&subject);
}

The cloned string no longer borrows the store, so the mutable receiver is available. The design now has two independent strings. That independence is both the benefit and the cost.

A snapshot fits when the operation needs a stable view while the source may change, the copy is bounded and measured, and the consistency contract permits staleness. It is a poor repair when cloning hides an unclear owner, enters an unbounded hot path, duplicates secrets longer than needed, or lets decisions use stale data without a version check.

The phrase “clone is cheap” is incomplete even for small data. State the type, size distribution, frequency, allocator behavior, and lifecycle. Arc::clone avoids copying the referent but adds shared ownership and atomic reference-count work; it is not the same trade-off as String::clone.

Compare repairs as architecture

Repair Ownership result Runtime cost Main semantic risk Review trigger
Borrow at leaf Store retains source; narrow fact copied Usually minimal Deriving too early may change meaning Operation begins retaining data
Split mutation domain Separate field borrows No required allocation Public API may expose internals Invariants span both fields
Move ownership One explicit owner at each stage Possible container movement Caller loses original access Work needs observation from multiple owners
Clone snapshot Independent owned copy Allocation/copy or reference-count work Staleness and duplicated sensitive data Input size/frequency or freshness changes
Interior mutability Shared outer access, runtime mutation check Dynamic checks or synchronization Panic, contention, reentrancy Mutation is no longer truly shared/local

Name the ownership semantics first. When they call for a snapshot, cloning is explicit and honest. When they call for one owner, cloning is a design defect even if benchmarks are fast.

The figure compresses the full workflow. Its left side preserves the diagnostic’s capability timeline; its right side carries each repair beyond compilation into cost, lifecycle, and restored production context.

A Rust E0502 diagnostic is annotated with its error code, primary conflicting-use span, secondary borrow-begins span, relationship note, and candidate help edit. Beside it, a six-step loop minimizes, states the contract, predicts a fix, runs cargo check, compares costs, and restores production context. Three repair cards compare borrowing at the leaf, moving ownership, and cloning a snapshot.
Diagnostics become design evidence when you explain the conflicting relationship before editing. A successful `cargo check` confirms the proposed type and borrow constraints; the cost and lifecycle comparison decides whether the repair belongs in production.

Make a minimal reproduction without deleting the cause

A useful minimal reproduction keeps:

  • the smallest types that preserve the ownership relationship;
  • the same receiver kinds (self, &self, &mut self);
  • the same control-flow boundary that extends the borrow;
  • relevant generic, closure, thread, or async bounds;
  • the edition and compiler release that produced the error.

Remove unrelated dependencies, logging, domain names, and macro layers when possible. If removing a line makes the error disappear, ask what relationship that line introduced. The goal is causal isolation, not merely fewer lines.

Common bad reductions include replacing a method with a free function that has different borrowing, removing the later reference use so non-lexical lifetimes shorten the borrow, converting a generic to a concrete type that changes trait selection, or dropping an .await that is the entire reason a value crosses suspension.

Run the smallest fast check:

cargo check --all-targets
rustc --edition 2024 ui/e0502_conflicting_borrow.rs
rustc --explain E0502

cargo check is the normal design loop because it analyzes package targets without waiting for final code generation and linking. A direct rustc command is useful for a standalone reproduction, but it does not replace Cargo’s feature, dependency, build-script, and target context.

Use inference and annotations as probes

Type inference reduces noise, but a strategic annotation can test a hypothesis or improve a public boundary:

let subject: &str = &store.names[0];

This confirms the intended borrowed view. It does not “fix inference” if the actual problem is overlapping borrows. An annotation that forces an owned String changes the design and should be reviewed as such.

Helpful probes include:

  • annotating a closure parameter or return at the API boundary;
  • naming an intermediate to reveal the inferred type in an error;
  • using an explicitly typed local to establish a coercion site;
  • temporarily replacing _ with a concrete type during reduction;
  • asking rust-analyzer for inferred types and selected methods.

Remove exploratory annotations that merely clutter the final code, but keep those that communicate public intent or prevent fragile inference.

Expansion, desugaring, MIR, and assembly answer different questions

Escalate tools according to the uncertainty:

Question First useful tool Boundary
Which target fails? cargo check --all-targets Package configuration evidence
What does an error code mean? rustc --explain and primary docs General explanation, not project design
Which type or method is selected? rust-analyzer, explicit probe, compiler diagnostic IDE display can lag or omit configuration
What tokens did a macro produce? expansion tooling Expanded form may be unstable and hard to read
What does syntax conceptually lower to? documented illustrative desugaring Not exact compiler output unless labeled
Why does borrow/drop behavior persist internally? MIR inspection Compiler implementation detail, not language contract
Did abstraction remain in machine code? optimized assembly plus benchmark Target/profile-specific observation

MIR and assembly are valuable when the question actually concerns compiler representation or generated cost. They are poor first responses to an ownership-design error. If a direct type signature and minimal program explain the relationship, deeper output adds volume rather than evidence.

Any MIR or assembly conclusion must record compiler commit, target, profile, features, and command. Never build an unsafe proof on an optimizer observation that the language does not guarantee.

Lints are policy, not taste automation

Rustc and Clippy lints surface suspicious constructs, edition migration concerns, unused results, public API issues, and style risks. Teams should decide which are denied, warned, allowed, or reviewed manually.

Useful discipline includes:

  • keep warnings visible in development;
  • deny selected correctness and project-policy lints in CI;
  • configure MSRV-aware lints when supporting older compilers;
  • scope allow narrowly and include a reason;
  • avoid enabling broad lint groups without reviewing false positives and churn;
  • separate formatter output from semantic edits when review clarity matters.

A lint-clean build does not prove an API is good. A lint exception does not prove the code is wrong. Lints encode patterns and policy; the design record explains judgment.

rust-analyzer accelerates the same loop

rust-analyzer can display inferred types, selected definitions, references, macro expansion, inlay hints, and diagnostics while editing. Configure it with the same workspace, target, features, environment, and toolchain as the command-line build. Otherwise the editor and CI may be analyzing different programs.

When editor and Cargo diagnostics disagree:

  1. Save files and check the active workspace root.
  2. Compare feature and target configuration.
  3. Confirm the toolchain and proc-macro/build-script state.
  4. Clear only the relevant analyzer state if needed.
  5. Treat the reproducible Cargo command as release evidence.

The editor shortens feedback; it does not redefine the build.

Repairs that conceal the question

Several familiar edits make the type error quieter by changing the problem. Adding 'static may forbid useful borrowed inputs. Adding move to a closure transfers captures whose ownership and retention still need review. Boxing can hide a type relationship behind allocation and dynamic dispatch. Arc<Mutex<_>> introduces shared ownership and synchronization even when the original problem was only an overbroad borrow.

The same concealment can happen in the investigation. A reduction that deletes the later use has deleted the cause. Reading only the primary span loses the capability timeline. MIR or assembly inspected before the source-level contract is understood replaces one compact explanation with a much larger implementation artifact. A green cargo check settles none of the remaining panic, performance, security, or operational questions.

Senior review checklist

  • Can the rejected relationship be stated without quoting the diagnostic?
  • Does the minimal reproduction preserve the same ownership and control-flow cause?
  • Which span creates the capability, which span conflicts, and where is the first capability last used?
  • What does each proposed repair change about owner, lifetime, allocation, indirection, synchronization, or freshness?
  • Is the help text being treated as a candidate rather than a command?
  • Did accepted repairs compile and receive behavior tests?
  • Is cargo check run with the production-relevant target and features?
  • Are lint levels intentional and exceptions explained?
  • Are rust-analyzer and command-line configurations aligned?
  • Is MIR or assembly used only for a versioned implementation question?

Lab: one error, three design records

Regenerate the E0502 failure from ui/e0502_conflicting_borrow.rs. Preserve the diagnostic output with toolchain and target metadata. Then implement and test the three accepted repairs in examples/compiler_repairs.rs.

For each repair, submit:

  1. the violated relationship;
  2. the changed ownership graph;
  3. allocations, copying, indirection, and mutation scope;
  4. panic, concurrency, and freshness consequences;
  5. a condition under which this repair becomes the wrong choice.

Finish with a decision for a store containing ten million names on a latency-sensitive audit path. A strong answer may select a fourth design, such as splitting audit state or passing a stable identifier, if it proves the original method receiver was too broad.

Durable takeaways

  1. Diagnostics describe rejected relationships; they do not select product architecture.
  2. Primary and secondary spans form a capability timeline that should be explained before editing.
  3. Borrowing narrowly, moving ownership, and cloning a snapshot are different semantics, not interchangeable compiler appeasement.
  4. cargo check, tests, lints, MIR, assembly, and benchmarks answer different questions.
  5. A repair is complete only after the production lifecycle, cost, failure, and compatibility context is restored.

The workflow ends Part I with a sharper question than “How do I satisfy the compiler?” The ownership work ahead is to predict exactly when values move, when duplication is implicit or explicit, when only one path becomes unavailable, and when borrowing is the truer contract.

Sources and version notes