Modules & visibility
Ladder:
src/bin/modules.rs· Run:cargo run --bin modules· Phase 0 · 9 rungs
TL;DR
A crate is a tree of modules rooted at the crate root. mod declares a node
in that tree — it imports nothing. Everything is private by default, and
privacy is tree-relative: an item is visible to its own module and all of that
module’s descendants; a parent can only see into a child what the child marks
pub. Paths walk the tree (crate:: from the root, super:: up one, self::
here). use is just a local alias for a path; pub use is a re-export that
adds a brand-new public path. Master those three facts and everything else —
field privacy, pub(crate), facades, sealed traits — is a corollary.
Why this exists (from first principles)
Without modules, every name in a crate lives in one flat namespace, and every function is callable from everywhere. Two problems follow immediately:
- No encapsulation. If any code can call any function and read any field,
you can’t enforce invariants. A
Celsiuscould be set to -1000; a half-built value could be observed. “It’s an internal detail” becomes a comment, not a guarantee. - No stable API. If your internal organization is your public surface, you can’t rename a helper or move a file without breaking everyone who depends on you. Refactoring becomes a breaking change.
Modules + visibility solve both. The module tree gives you namespaces and a
place to hide things; pub and its restricted variants let you publish
exactly the surface you intend, and nothing more. The compiler enforces it —
privacy is a checked rule, not advice. That’s the whole point: the things you
don’t mark pub are things you are free to change.
The ladder at a glance
| # | Tier | Rung | The lesson |
|---|---|---|---|
| 1 | Foundations | Module tree & paths | mod builds a tree; crate::/super::/self:: walk it |
| 2 | Foundations | pub opens a door | Privacy is tree-relative; keep helpers private |
| 3 | Mechanics | Field privacy | pub struct ≠ pub fields; private field + smart constructor guards an invariant |
| 4 | Mechanics | use & pub use | Local alias vs re-export; flatten a deep tree |
| 5 | Footgun | Leaking a private type | A pub fn can’t honestly expose a less-visible type |
| 6 | Footgun | Restricted visibility | pub(crate) / pub(super) / pub(in path): pick the narrowest reach |
| 7 | Real-world | Facade pattern | Private internals, curated public surface via pub use |
| 8 | Real-world | Sealed trait | A private-module supertrait gates who can impl |
| 9 | Capstone | inventory mini-library | A full module tree with a real front door |
The ideas, built up
1. A module is a node; paths walk the tree
mod kitchen { ... } does not “import” anything. It creates a node named
kitchen under the current module, and the items inside it live at
kitchen::.... To reach an item you spell a path, and the prefix you choose
says where the path starts:
fn oven_temp() -> u32 { 220 } // lives at the crate ROOT
mod kitchen {
pub mod pantry {
pub fn flour_grams() -> u32 { 500 }
}
pub mod stove {
pub fn bake() -> (u32, u32) {
// pantry is a SIBLING of stove → go up one (super), then down
// oven_temp is at the ROOT → start from the root (crate)
(super::pantry::flour_grams(), crate::oven_temp())
}
}
}
Three prefixes, three starting points:
| Prefix | Starts at | Use when |
|---|---|---|
crate:: | the crate root (absolute) | the item lives near the root / “shared, top-level” |
super:: | the parent module (relative, up one) | reaching a tight sibling or parent helper |
self:: | the current module (relative) | disambiguating a local name; rarely needed |
Key insight:
super::pantryandcrate::kitchen::pantryresolve to the same item here. The difference is robustness to change.super::survives the whole subtree being moved or renamed;crate::survives local shuffling. Reach forsuper::for tight siblings,crate::for things that live near the root.
2. pub opens a door — and privacy is about the tree
By default an item is private to its module. Privacy is relative: an item is
visible to its defining module and every descendant of that module. A parent
sees a child’s item only if it’s pub.
mod billing {
fn tax_rate() -> u32 { 8 } // PRIVATE: internal detail
pub fn total_with_tax(price: u32) -> u32 { // PUBLIC entry point
price + price * tax_rate() / 100 // can call the private helper
}
}
// at the crate root:
billing::total_with_tax(100); // OK — it's pub
// billing::tax_rate(); // E0603: function `tax_rate` is private
Two subtleties worth internalizing:
mod billingitself needed nopubbecausebillingand its caller both live at the crate root — same module, already visible. Ifbillingwere nested inside another module, the outside would needpub mod billing.E0603is a hard error. Privacy isn’t a lint you can ignore; the compiler refuses to let unrelated code reach in.
3. pub struct is not pub fields
Marking a struct pub makes the type nameable. Its fields stay private unless
each one is individually pub. That asymmetry is the single most useful tool in
the whole topic: publish the type, hide the data, force everyone through your
methods.
mod temperature {
#[derive(Debug, PartialEq)]
pub struct Celsius {
degrees: i32, // PRIVATE — note: no `pub`
}
impl Celsius {
pub fn new(d: i32) -> Option<Celsius> { // smart constructor
if d >= -273 { Some(Celsius { degrees: d }) } else { None }
}
pub fn get(&self) -> i32 { self.degrees }
}
}
// let bogus = temperature::Celsius { degrees: -1000 }; // E0451: field is private
Because the field is private, the struct literal Celsius { degrees: ... } is
forbidden outside the module (E0451), and reading .degrees directly is too
(E0616). The only way to obtain a Celsius is new, which checks the
invariant. This is parse, don’t validate enforced by the module system: a
Celsius existing is proof it’s valid — downstream code never re-checks.
Inside the defining module you can still use the literal freely — privacy only bites when you cross the module boundary.
4. use aliases; pub use re-exports
These look similar and do fundamentally different things.
mod deep { pub mod nested { pub mod core {
pub struct Engine { pub power: u32 }
impl Engine { pub fn new(power: u32) -> Engine { Engine { power } } }
}}}
fn start_deep() -> u32 {
use crate::deep::nested::core::Engine; // LOCAL alias — only this fn sees it
Engine::new(9000).power
}
pub use deep::nested::core::Engine; // RE-EXPORT — adds crate::Engine
useis private plumbing: it shortens a path for the current scope and changes nobody else’s view of the tree.pub useis API surface: it makes the item reachable through a new path (crate::Engine), in addition to its real one.
This is how every mature crate is laid out: deep folders internally for
organization, a thin layer of pub use at the root so users write tokio::spawn
instead of tokio::runtime::task::spawn. The internal tree is an implementation
detail; the re-exports are the contract.
5. You can’t leak a private type through a public API
If a pub fn returns (or accepts) a type less visible than the function
itself, that’s a leak: an outside caller would receive a value of a type they
can’t name or use. The compiler stops you.
mod widget {
pub struct Inner { pub id: u32 } // must be pub to be honestly returned
pub fn make(n: u32) -> Inner { Inner { id: n } }
}
let w: widget::Inner = widget::make(7); // naming the type needs it pub (else E0603)
assert_eq!(w.id, 7); // reading the field needs it pub (else E0616)
Where the error shows up depends on the crate kind. In a library crate, exposing a private type in a
pubsignature fires theprivate_interfaceslint (and historically the hard errorE0446). In a binary like this ladder, there’s no external consumer, so the leak instead bites at the use site: callers can’t name the private type (E0603) or read its private fields (E0616).
The fix isn’t to silence the error — it’s to decide the type’s honest visibility. If you return it, you must publish it (this rung). If it should have stayed internal, don’t return it — hide it behind a facade (rung 7).
6. The visibility dial: pub(crate), pub(super), pub(in path)
pub and private are the two extremes. Between them sits a dial: “public, but
only up to here.”
mod engine {
pub(crate) const fn version() -> u32 { 3 } // anywhere in THIS crate
pub mod fuel {
pub(super) const fn secret_formula() -> u32 { 42 } // only the parent `engine`
}
pub mod electrical {
pub(in crate::engine) const fn calibrate() -> u32 { 7 } // the engine subtree
}
pub fn diagnostics() -> u32 {
fuel::secret_formula() + electrical::calibrate() // engine can see both
}
}
// engine::fuel::secret_formula(); // privacy error: pub(super) doesn't reach the root
| Marker | Visible to | Typical use |
|---|---|---|
pub(crate) | anywhere in this crate, not downstream | the workhorse: “shared internal API” |
pub(super) | the parent module and its descendants | a child exposing something to just its parent |
pub(in path) | within the named ancestor subtree | precise scoping when crate-wide is too loose |
Pick the narrowest visibility that satisfies the actual callers. In the ladder,
VERSIONcould even stay fully private, because the only thing reading it (version()) lives in the same module.pub(crate)would only earn its keep if some other module needed to readVERSIONdirectly. Don’t reach for a wider marker than the call sites demand.
Footguns
- Forgetting fields aren’t auto-
pub.pub struct Foo { x: i32 }exposes the type but notx. Outside code can’t build it with a literal (E0451) or readx(E0616). Usually that’s what you want — but it surprises people who expectedpub structto mean “all public.” - Returning a private type from a
pub fn.private_interfaceslint in libs,E0603/E0616at use sites in bins. The cure is to decide the type’s real visibility, not to paper over the symptom. pub(super)doesn’t reach the crate root unless the item’s parent is the root. It’s exactly one level of upward reach (plus descendants of that parent).usevspub usemix-up. A plainusein your lib root does not expose anything to downstream crates — it only aliases for your own code. If you meant to re-export, you needpub use.- Over-widening visibility to “make it compile.” The compiler will happily
accept
pubeverywhere; then your entire internal structure becomes API you can’t change. Tighten to the minimum the callers actually need.
Real-world patterns
The facade: private internals, curated surface
This is how production crates are organized. Implementation lives in private
modules; a thin layer of pub use exposes only the handful of names that form
the public API.
mod api {
mod internal { // PRIVATE — no `pub`
struct RawSocket; // guts the public API must NOT expose
pub struct Client { pub name: &'static str }
impl Client {
pub fn connect(name: &'static str) -> Self { Self { name } }
pub fn ping(&self) -> &'static str { "pong" }
}
}
pub use internal::Client; // re-export ONLY Client
}
api::Client::connect("db-1"); // OK
// api::internal::RawSocket; // E0603 — `internal` is private, path sealed
The payoff: you can rename RawSocket, split internal into ten files,
restructure freely — and nothing downstream breaks, because the only public
path is the curated pub use. The module tree stops being part of your API
contract.
The sealed trait: a private-module supertrait
Privacy can gate not just calling but implementing. Make your public trait
require a supertrait that lives in a private module. Outsiders can see and
call your trait, but can’t impl it — satisfying it requires impl’ing the
supertrait, which they can’t even name.
mod format {
mod sealed { pub trait Sealed {} } // private module → unnameable downstream
pub trait Encoder: sealed::Sealed { // supertrait bound is the seal
fn encode(&self, input: &str) -> String;
}
pub struct Json;
impl sealed::Sealed for Json {} // only possible INSIDE `format`
impl Encoder for Json {
fn encode(&self, input: &str) -> String { format!("json:{input}") }
}
}
// Downstream:
// struct Rogue;
// impl format::Encoder for Rogue { ... } // error: Rogue: format::sealed::Sealed
// // not satisfied — and you can't impl it
The “sealed trait” you meet in typestate and blanket_coherence is, mechanically,
just this: privacy applied to a supertrait. Because no downstream impl can ever
exist, you’re free to add methods to Encoder later without breaking anyone — a
genuine API-evolution tool.
Capstone insight
Rung 9 assembles everything into an inventory “library in a file”:
inventory (the library root)
├── util (PRIVATE) pub(crate) normalize() — shared by submodules
├── model (PRIVATE) Sku (private field + smart constructor), Item
├── store (PRIVATE) Warehouse (private items: Vec<Item>)
└── (facade) pub use model::{Sku, Item}; pub use store::Warehouse;
The structural “aha”: every visibility decision is driven by who the actual caller is, and the public surface is a deliberate, tiny re-export layer.
util::normalizeispub(crate)— bothmodelandstorecall it, so crate-wide is the right reach, but it’s not part of the public API. (Apub(super)would have been too narrow once two different submodules needed it.)Skuispubwith a privatecodefield: the only way to get one is the normalizing, length-checkingnew, so an existingSkuis provably valid.- The three submodules (
util,model,store) are all private. The only way in is the facade —inventory::Skuworks,inventory::model::Skudoes not. - Two seal probes prove it:
inventory::store::Warehouse::new()is unreachable (the submodule is private) andinventory::Sku { code: ... }is forbidden (the field is private). The invariant and the encapsulation are both enforced, not hoped for.
Build this once and the mental model locks in: the module tree is your private
workshop; pub use is the storefront window; and the narrowest pub(...) that
satisfies the real callers is always the right answer.
Explain it back
- Why does
mod foo;not “import” anything? What does it actually do? super::barvscrate::foo::barresolve to the same item — when would you prefer each, and why?- Why does marking a struct
pubnot make its fields public, and how is that the foundation of “parse, don’t validate”? - What’s the difference between
use path::Thingandpub use path::Thing? Which one is part of your crate’s public API? - A
pub fnreturns a private struct. What happens in a library crate vs a binary crate, and what are the two honest fixes? - When would you choose
pub(crate)overpub(super)? Over leaving it private? - How does a private module turn a public trait into a sealed trait, and why is that an API-evolution tool?
- In the facade pattern, what exactly are you free to change without breaking downstream code, and why?
See also
- The typestate pattern — sealed
Statetrait via a private supertrait. - Blanket impls & coherence — the sealed extension-trait pattern.
- API evolution & semver — sealed traits and
#[non_exhaustive]as evolution levers. - Newtype & zero-cost wrappers — private fields + smart constructors for invariants.