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:
- Error code and headline. The code is a stable search handle; the headline summarizes the conflicting operation.
- Primary span. This is where the rejected use becomes unavoidable—here, the mutable receiver borrow for
audit. - Secondary span. This shows where the conflicting immutable borrow begins and where it is later used.
- Notes. Notes connect spans into a relationship: one borrow remains active across another incompatible borrow.
- 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;
subjectborrows 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:
- The call needs only a small derived value; end the field borrow before mutation.
- The operation is a lifecycle boundary; move the subject or the store into an owning operation.
- 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.
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
warningsvisible in development; - deny selected correctness and project-policy lints in CI;
- configure MSRV-aware lints when supporting older compilers;
- scope
allownarrowly 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:
- Save files and check the active workspace root.
- Compare feature and target configuration.
- Confirm the toolchain and proc-macro/build-script state.
- Clear only the relevant analyzer state if needed.
- 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 checkrun 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:
- the violated relationship;
- the changed ownership graph;
- allocations, copying, indirection, and mutation scope;
- panic, concurrency, and freshness consequences;
- 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
- Diagnostics describe rejected relationships; they do not select product architecture.
- Primary and secondary spans form a capability timeline that should be explained before editing.
- Borrowing narrowly, moving ownership, and cloning a snapshot are different semantics, not interchangeable compiler appeasement.
cargo check, tests, lints, MIR, assembly, and benchmarks answer different questions.- 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
- The rejected fixture and three accepted repairs were regenerated with Rust 1.97.0, Edition 2024, on
x86_64-unknown-linux-gnu, 2026-07-11. - Rust error index: E0502
- The Cargo Book:
cargo check - rustc book: lint levels
- Clippy documentation
- rust-analyzer manual
- rustc development guide: MIR
- Reproduction:
ui/e0502_conflicting_borrow.rsandexamples/compiler_repairs.rsin the Part I fixture.
Continue reading
Full table of contents