The Rust Engineering Handbook / Chapter 46
Designing Public Types and Traits for Evolution
Expose stable construction, matching, implementation, and concurrency contracts while keeping representation replaceable.
A library team wants to replace this v1 type without a major release:
pub struct RecordId(pub String);
pub enum MatchMode {
Exact,
Prefix,
}
pub trait SelectionPolicy {
fn allows(&self, record: &Record) -> bool;
}
The internal plan sounds modest: store short identifiers inline, add a glob match mode, cache policy decisions, and execute selection in worker threads. Downstream source reveals the actual scope. Users construct RecordId("edge-7".into()), read and replace .0, exhaustively match MatchMode, implement SelectionPolicy for borrowed thread-confined adapters, and pass those implementations through generic functions with no Send or Sync requirement.
Nothing in those uses is an implementation detail. Public fields granted construction and mutation rights. An exhaustive enum granted knowledge of the complete case set. An open trait granted implementation ownership. Missing concurrency bounds allowed thread-confined implementations. The proposed v2 changes all four contracts.
The governing rule is: stabilize the operations downstream code may rely on; keep representation, case space, implementation ownership, and auto-trait behavior private unless committing them is intentional. Evolution freedom is not produced by hiding everything. It comes from choosing a small, useful shell whose promises the library can maintain.
Four gates determine the remaining design space
Every public type and trait should pass four separate reviews:
- Construction: can users create invalid values or depend on field layout and names?
- Matching: can users assume they know every case or destructure every field?
- Implementation: can downstream crates create new behavior that the library must forever accommodate?
- Auto traits: do users rely on values or returned opaque types being
Send,Sync,Unpin, unwind-safe, or otherwise capability-bearing?

These gates fail independently. Private fields do not make an exhaustive enum extensible. Sealing a trait does not preserve Send. A newtype does not protect an invariant if From<String> accepts every string. Review each promise explicitly.
Keep the review retrievable by recording one line per gate: the downstream right granted today, the witness that exercises it, and the future change that right constrains. That ledger prevents a discussion about field privacy from silently standing in for enum growth, trait implementation rights, or thread mobility. The sections below supply the design choices; the four-line ledger remains the release artifact.
Private fields move construction through policy
The fixture replaces the tuple field with a private representation and a smart constructor:
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct RecordId(Box<str>);
impl RecordId {
pub fn parse(input: &str) -> Result<Self, InvalidRecordId> {
// validate, then construct
}
pub fn as_str(&self) -> &str {
&self.0
}
}
Downstream users can create an identifier only through an operation that enforces the documented invariant. They can observe text without learning whether storage is String, Box<str>, inline bytes, an interned handle, or a compact validated encoding. The public contract is “a validated record identifier with textual access,” not “a String in field zero.”
Private fields reserve more than representation. They prevent external struct literals from bypassing validation, prevent partial mutation that temporarily breaks the invariant, and let constructors gain derived fields or caches. They also impose a responsibility: the library must supply enough constructors, accessors, conversions, and update operations for legitimate work. Privacy without usable operations produces wrapper-breaking escape hatches.
Smart constructors should state normalization and failure
A constructor name and result type should distinguish operations with different meaning:
RecordId::parse(&str) -> Result<RecordId, InvalidRecordId>validates external text and can fail.RecordId::new(...)is appropriate when inputs are already typed such that construction is expected to succeed.RecordId::from_static(&'static str)would need either compile-time enforcement, a documented panic, or a fallible result; “static” does not imply valid.normalize(&str) -> RecordIdwould promise transformation rather than rejection and must specify case folding, Unicode, separators, and collision behavior.
Avoid a blanket From<String> when arbitrary strings can be invalid because From conventionally represents infallible conversion. TryFrom<String> can preserve failure, but accepting owned text need not dictate the internal representation. The constructor API is the invariant boundary.
Newtypes are also coherence and domain boundaries. RecordId cannot be accidentally exchanged with TenantId even if both contain text, and the defining crate can implement local or foreign traits where the orphan rules permit. Deref<Target = str> may feel convenient, but it exposes a large method surface and encourages callers to treat the domain value as unrestricted text. A named as_str plus selected trait implementations such as AsRef<str>, Display, Borrow<str>, or parsing traits gives the library deliberate control. Each implementation should reflect a true, stable relationship.
Public structs need a construction policy, not just visibility
For a record with private fields, accessors and constructors preserve the ability to add metadata or change payload storage:
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Record {
id: RecordId,
payload: Arc<str>,
}
impl Record {
pub fn new(id: RecordId, payload: impl Into<Arc<str>>) -> Self;
pub const fn id(&self) -> &RecordId;
pub fn payload(&self) -> &str;
}
Making fields private means adding a field is normally not a source-breaking construction change because downstream crates cannot write a literal or destructure the fields. It may still change observable behavior, layout, performance, auto traits, serialization, or FFI. Privacy reserves source-level representation freedom; it is not invisibility.
For data-transfer structs whose fields intentionally are the protocol, public fields may be correct. Then field names and types are versioned schema. Adding, changing, or removing them requires the protocol’s compatibility mechanism. Do not use privacy reflexively when direct construction and destructuring are the product, but do not pretend that such a product retains opaque internals.
#[non_exhaustive] on a struct prevents construction with a literal and requires downstream matches to include ... It can reserve field growth, but it also makes even current fields unavailable for external literal construction. Named constructors and builders must carry the usability load. Inside the defining crate the attribute does not restrict construction or exhaustive matching, which is useful for internal maintenance and means tests must include a downstream perspective.
Non-exhaustive enums reserve case space
An ordinary public enum invites exhaustive downstream matching:
match mode {
MatchMode::Exact => exact(),
MatchMode::Prefix => prefix(),
}
Adding Glob breaks that source. If new modes are a credible evolution path, declare the enum non-exhaustive from v1:
#[non_exhaustive]
pub enum MatchMode {
Exact,
Prefix,
}
Downstream code must include a wildcard arm. That preserves source compatibility for a new variant, but not automatically behavioral correctness. A wildcard that treats every future mode as exact could be wrong. Documentation should tell callers whether unknown modes should be rejected, conservatively handled, logged, or delegated back to the library.
Non-exhaustiveness has a cost: the compiler cannot prove downstream matches complete even for today’s version, and tests may overlook meaningful new behavior. Closed domain states—Boolean-like outcomes, a mathematically fixed set, or a protocol version frozen by an external standard—may benefit from exhaustive matching. Use #[non_exhaustive] when the library owns and expects to extend the case set, not as a universal SemVer charm.
For error enums, future variants are likely, but wildcard handling affects retry, alerting, and security. Offer classification methods such as is_transient() or kind() when callers need a stable decision rather than full variant knowledge. Those methods themselves become behavioral contracts and require careful evolution.
Sealed traits reserve implementation ownership
An open public trait has two audiences: callers use its behavior, and downstream crates implement it. Adding a required method breaks implementors. Adding a provided method can collide with extension methods or change resolution. Adding supertraits rejects existing implementations. Changing receiver types, associated items, dyn compatibility, or laws can be equally disruptive.
If the library needs a closed family of policies but wants generic or dynamic dispatch internally, seal the trait:
mod private {
pub trait Sealed {}
}
pub trait SelectionPolicy: private::Sealed + Send + Sync {
fn allows(&self, record: &Record) -> bool;
}
The public trait is nameable and usable as a bound, but an external type cannot satisfy the private supertrait. The library retains authority to add required methods or coordinate all implementations. Sealing is an API promise too: users cannot supply policies. It is correct only when the extension point is intentionally library-owned.
Common alternatives have distinct contracts:
- Open trait: downstream implementation is a supported extension mechanism; evolve conservatively and specify laws.
- Sealed trait: users select among library implementations; the library owns implementation evolution.
- Enum: a closed set with explicit cases and often simpler diagnostics and serialization.
- Callback: behavior is supplied per operation without creating a long-lived nominal implementation ecosystem.
- Data configuration: users describe policy and the library executes it, often best for validation, persistence, and remote boundaries.
Do not expose a trait merely to mock one implementation in the library’s own tests. A private adapter or generic internal boundary can provide test substitution without creating a permanent downstream implementation contract.
Extension traits organize methods; they do not grant future name safety
An extension trait adds methods to types the crate does not own or groups opt-in capabilities:
pub trait RecordSliceExt {
fn newest(&self) -> Option<&Record>;
}
impl RecordSliceExt for [Record] {
fn newest(&self) -> Option<&Record> {
self.last()
}
}
Users must import the trait to call the method. This is useful namespacing, but a future inherent method or another imported extension trait may create shadowing or ambiguity. Choose specific names, keep the surface cohesive, and treat adding methods as a compatibility review rather than risk-free growth.
An extension trait can be sealed when implementations should remain controlled, or open when downstream types should participate. State which. “Extension” describes how methods are made available, not who owns implementations.
Associated types define one relationship per implementation
The fixture’s open source trait uses an associated error:
pub trait RecordSource {
type Error: std::error::Error + Send + Sync + 'static;
fn fetch(&self, query: &Query) -> Result<Option<Record>, Self::Error>;
}
An associated type says each RecordSource implementation chooses one Error type. Callers can constrain it when needed, and method calls do not carry an extra error type parameter to infer. A generic trait such as RecordSource<E> would permit the same source type to implement the trait for multiple E values if coherence permits; that is a different relationship.
Associated types become part of the implementation contract. Adding a required associated type breaks downstream implementors. Strengthening its bounds can also break them. Exposing it in other public types can propagate complexity. Decide early whether downstream implementation is intended and whether the associated choice truly belongs to the implementor.
Generic associated types can express families such as a borrowed item type indexed by a lifetime, but they raise the semantic and diagnostic cost of the API. Use them when the family is essential, not to mirror an implementation abstraction. Core public APIs should remain stable-first and should verify their exact stable compiler and MSRV requirements.
Trait laws are part of correctness even when the compiler cannot enforce them
Method signatures prove shape, not meaning. SelectionPolicy::allows can type-check while returning a different answer on each call, mutating global state, blocking indefinitely, or retaining data through an interior mechanism. Whether those behaviors are legal must be documented.
The fixture states two laws: the answer is deterministic for the same record, and the implementation must not retain a reference after the call. A production policy may need more:
- no panic for valid
Recordvalues; - no blocking or external I/O on a latency-critical path;
- bounded execution time;
- thread-safe concurrent calls when shared;
- consistency with equality or hashing if used in a cache;
- no callback into the selecting collection while its lock is held.
Laws should be testable where possible. Supply a conformance test function that accepts an implementation and representative inputs. Property tests can check determinism, equivalence, or ordering. Documentation remains necessary because a finite test suite cannot prove every semantic law.
Unsafe traits require a sharper contract: every obligation that an implementation must uphold for unsafe code to remain sound. That subject belongs to the later unsafe-code part. A safe trait law may affect correctness and compatibility; an unsafe trait obligation may affect memory safety. Do not blur them.
Implement common traits only when their semantics are unsurprising
Deriving Debug, Clone, Eq, and Hash is not automatic API hygiene. Each implementation gives downstream generic code a promise:
Clonesays duplication is meaningful, though not necessarily cheap or deep.EqandHashmust agree; changing which fields participate can change map behavior and persisted expectations.Orddeclares a total ordering that users may persist or expose in output.Defaultsays one value is a sensible context-free default, not merely constructible.Displayestablishes a user-facing textual form;FromStrestablishes parsing and should document round-trip relationships.Errorintegrates failure sources and exposessource()relationships.
Derive when field-wise behavior matches the public semantics and will remain acceptable if representation changes. Implement manually when the public identity differs from storage. Omit a trait when no stable, unsurprising meaning exists. Convenience today can freeze accidental semantics tomorrow.
Public aliases deserve similar care:
pub type Records = Vec<Record>;
This alias does not create a new abstraction. Users can call every Vec method, rely on ordering and contiguous storage, and assign ordinary vectors directly. Replacing it with a set or custom collection is a breaking type change. A newtype with selected operations costs forwarding code but creates an owned contract. Use an alias to publish the underlying type relationship deliberately, not to pretend it is hidden.
Send and Sync can change when private fields change
Auto traits are inferred structurally. A type containing only thread-safe fields may automatically implement Send and Sync; replacing a private field with Rc, Cell, a raw pointer wrapper, or a thread-affine handle may remove one or both. Downstream users can rely on those implementations even if the crate never wrote them explicitly.
Conversely, adding Send + Sync as supertraits to an open trait rejects implementations built from Rc, borrowed thread-confined state, or non-thread-safe foreign handles. The v1 selection trait cannot become the fixture’s sealed Send + Sync trait without a breaking change if external implementations were allowed.
Choose one of three policies:
- Promise the auto traits. Add compile-time assertions in tests, document the capability, and constrain future internals accordingly.
- Avoid promising them. This is difficult once a public concrete type structurally has them because downstream code may already compile against the capability; compatibility policy must still treat removal seriously.
- Separate types. Offer a local handle and a deliberately thread-safe handle, or keep thread-affine internals behind an actor/channel whose public handle has an explicit concurrency contract.
Do not write unsafe impl Send or unsafe impl Sync to preserve compatibility unless a complete safety argument proves all fields and operations support the promise. Auto-trait pressure is not a safety proof.
Opaque return types also carry auto traits. A function returning impl Iterator<Item = &Record> + Send promises a sendable hidden type; one omitting Send gives callers no such named guarantee even if today’s adaptor happens to implement it. As Chapter 45 established, hidden concrete types preserve implementation freedom only within the bounds actually exposed and behavior documented.
Hidden concrete types create a controlled seam
impl Trait in return position lets a function expose a capability without naming its concrete iterator, future, or adapter:
pub fn visible<'a>(
records: &'a [Record],
policy: &'a impl SelectionPolicy,
) -> impl Iterator<Item = &'a Record> + 'a;
The library can replace Filter<Iter<...>> with another single concrete implementation while preserving the item and lifetime contract. Callers cannot name the hidden type, construct it, or depend on its fields. They can still observe iteration order, size hints, fused behavior if promised, auto traits included in the bound, panic behavior, and performance characteristics documented as guarantees.
Returning Box<dyn Iterator<...>> provides runtime type erasure and allows multiple concrete branches, at the cost of allocation and dynamic dispatch. Returning a named iterator type gives maximum control over implemented traits and documentation but makes its name and generic parameters public. Returning Vec is eager and representation-revealing but may be operationally simpler. Select the seam based on required variation, cost, and compatibility—not on which syntax is shortest.
Public generic bounds can freeze internal needs. If a function requires S: RecordSource + Clone + Send + Sync + 'static only because the current body spawns tasks and clones S, every caller must adopt that architecture. Prefer accepting the minimum semantic capability and adapting internally. When thread movement is intrinsic to the operation—such as registering a long-lived source with a multithreaded service—name it and explain retention and shutdown behavior.
Evolve the v1 domain API deliberately
Return to the proposed refactor. A viable v1 designed for evolution would make these choices:
RecordIdis a private-field newtype with fallible parsing and textual access. Storage may change if validation, equality, hashing, text, allocation, and auto-trait promises remain satisfied.MatchModeis#[non_exhaustive]because the library expects to add modes. Callers receive guidance for wildcard behavior.Queryhas private fields, named constructors, and accessors; adding internal cache state or a field does not break external literals because none exist.SelectionPolicyis sealed and declaresSend + Syncfrom v1 if users only select library policies and worker execution is intrinsic.- A separate open
RecordSourcetrait supports downstream implementations. Its associated error, laws, concurrency expectations, and evolution policy are documented conservatively. - Iterator implementation types remain hidden behind
impl Iterator, with only necessary lifetime and auto-trait bounds promised.
If v1 has already shipped with the opening public surface, these are not patch-level cleanups. The team should inventory downstream witnesses, classify breaks, deprecate old construction paths where possible, introduce parallel v2 types or traits, provide conversions and a migration guide, and choose a major release according to the compatibility policy from Chapter 44. Type privacy cannot be retroactively asserted without migration.
Review the shell and the reserved space
For every public type or trait, record:
- invariant and every operation that can create or mutate a value;
- whether fields, variants, and case space are intentionally open or closed;
- whether downstream construction and pattern matching are supported workflows;
- who may implement each trait and why;
- required methods, provided methods, associated items, supertraits, object use, and laws;
- common trait implementations and their semantic meaning;
- promised
Send,Sync,Unpin, unwind, and lifetime capabilities; - aliases or dependency types whose representation becomes public;
- hidden concrete return types and all observable bounds or behaviors;
- generic bounds that reflect caller semantics rather than current implementation convenience;
- witness programs for construction, matching, implementation, generic use, trait objects, and thread movement.
Then write invariants in the API documentation where users make decisions. State valid values, normalization, equality, ordering, panic and failure behavior, mutation rules, thread capabilities, and trait laws. “Fields are private” is not documentation of what a value means.
Exercise: carry a v1 API through a real v2
Start with the opening RecordId, MatchMode, and SelectionPolicy. The v2 requirement is to store short IDs inline when profitable, add Glob, cache deterministic policy decisions, and run policies concurrently.
Produce two designs:
- an evolution-aware v1 that can accept those v2 changes without source breakage where the promised behavior remains valid;
- a migration plan for the already-shipped open v1.
For each, include:
- construction and validation examples from a downstream crate;
- exhaustive or wildcard match behavior and its operational fallback;
- sealed-versus-open trait decision and at least one credible downstream implementation;
- associated type and trait-law policy;
Clone,Eq,Hash,Display,Default, and conversion decisions with rationale;- compile-time witnesses for
SendandSyncpromises; - a comparison of named, opaque, boxed-dynamic, and eager collection returns;
- SemVer classification, deprecation path, and migration text for every unavoidable break.
The exercise succeeds when v2 can replace internals without changing unpromised details, and every remaining break traces to a useful public capability that v1 deliberately granted—not to an accidental field, bound, alias, or inferred property.
Publish operations; retain representation freedom
Private fields and smart constructors make invariants enforceable. Newtypes establish domain identity. Non-exhaustive types reserve case and field space when extension is expected. Sealed traits retain implementation authority; open traits require durable associated-item and law design. Common traits, aliases, generic bounds, and auto traits are public promises even when generated or inferred. Opaque types hide representation only up to their declared capabilities.
A stable library is not one that never changes. It is one whose downstream users can distinguish the stable shell from replaceable internals and whose maintainers know which construction, matching, implementation, and concurrency rights each release must preserve. With that shell established, the next chapter can design complex construction flows—builders, layered configuration, validation timing, and typestate—without surrendering the invariant boundary.
Sources and verification notes
- Rust Reference: visibility and privacy, implementations, traits, type aliases, and implementation traits.
- Standard library documentation:
Send,Sync, and common conversion, comparison, hashing, formatting, and error traits. - Rust Reference:
non_exhaustive. - Rust API Guidelines: future proofing, type safety, interoperability, and documentation.
- Executable source:
examples/rust-engineering-handbook/part-08/public-api-evolution-lab/. The crate declares Rust 1.85 as its MSRV; its sealed-trait, non-exhaustive, associated-type, auto-trait, and opaque-return witnesses are intended to be rerun across the project’s supported toolchain matrix.
Continue reading
Full table of contents