Skip to content
rusthacks.dev
Rust

Modules and Visibility in Rust Without the Confusion

Modules organize Rust code and control what is public. Learn mod, pub, use, super, self, file layout and why privacy is by module and not by type.

8 min read
Summarize in AI
Flat illustration for the post “Modules and Visibility in Rust Without the Confusion”, composed around ownership.

Modules are where newcomers meet their first confusing compiler errors outside of ownership, and the confusion has one root: a module tree is something you declare, not something the compiler discovers from your directory. Once you see that the file system only matters after you write mod, the rules fit on one page. This post builds a small shop crate with three modules, shows the files, and then breaks it four different ways so you can recognise each error. Everything compiled and ran on Rust 1.99, edition 2024.

The project

The crate has an inventory module, a billing module with a child module for invoices, and a main that wires them together. The tree on disk:

Text
src/
  main.rs
  inventory.rs
  billing/
    mod.rs
    invoice.rs

main.rs declares the two top-level modules and uses items from them:

src/main.rsrust
mod billing;
mod inventory;

use billing::invoice::Invoice;
use inventory::Item;

fn main() {
    let item = Item::new("keyboard", 4999, 3);
    println!("{} costs {}", item.name, item.price_display());

    let mut invoice = Invoice::new();
    invoice.add(&item, 2);
    println!("{}", invoice.summary());
    println!("stock left: {}", item.stock());
}
Text
keyboard costs $49.99
subtotal 9998 cents + tax 799 cents = 10797 cents
stock left: 3

The two mod lines are the declarations. mod inventory; tells the compiler: there is a module called inventory, load its body from inventory.rs or inventory/mod.rs. Without that line the file is ignored entirely, which surprises people who expect every file under src to be compiled. The use lines are separate and only shorten paths: use inventory::Item; lets main write Item instead of inventory::Item.

Privacy is the default

Here is inventory.rs:

src/inventory.rsrust
pub struct Item {
    pub name: String,
    price_cents: u32, // private: only this module can read or write it directly
    stock: u32,
}

impl Item {
    pub fn new(name: &str, price_cents: u32, stock: u32) -> Item {
        Item {
            name: name.to_string(),
            price_cents,
            stock,
        }
    }

    pub fn price_display(&self) -> String {
        format_money(self.price_cents)
    }

    pub fn stock(&self) -> u32 {
        self.stock
    }

    // Visible to the rest of the crate, but not to users of a library built from it.
    pub(crate) fn price_cents(&self) -> u32 {
        self.price_cents
    }
}

// Private helper: nothing outside `inventory` may call it.
fn format_money(cents: u32) -> String {
    format!("${}.{:02}", cents / 100, cents % 100)
}

Item is pub, and so are its name field and the functions new, price_display and stock. The two fields price_cents and stock have no pub, so only code inside the inventory module can touch them directly; outside code reads the stock through the stock() method. This is the module system doing the job that private fields do in a class: the type's author decides which parts of its representation are part of the contract. Marking a struct pub does not make its fields public. A struct whose fields are all private cannot even be built with a literal outside the module, which is why Item::new exists.

The function format_money is private too. It is an implementation detail of price_display, and nothing outside inventory may call it. Try anyway:

rust
println!("{}", inventory::format_money(5)); // added to main
Text
error[E0603]: function `format_money` is private
  --> src/main.rs:14:31
   |
14 |     println!("{}", inventory::format_money(5));
   |                               ^^^^^^^^^^^^ private function
   |
note: the function `format_money` is defined here
  --> src/inventory.rs:31:1
   |

E0603, function format_money is private. The compiler points at the call and at the definition, and the fix is a decision rather than a keyword: either the function should be part of the API (add pub) or the caller should use price_display instead. Making things pub to silence an error is the habit to avoid; a pub function is a promise that you will keep it working.

Fields work the same way. Reading item.price_cents from main instead of calling the method:

rust
println!("{}", item.price_cents); // field, not method
Text
error[E0616]: field `price_cents` of struct `Item` is private
  --> src/main.rs:14:25
   |
14 |     println!("{}", item.price_cents);
   |                         ^^^^^^^^^^^ private field
   |
help: a method `price_cents` also exists, call it with parentheses
   |
14 |     println!("{}", item.price_cents());
   |                                    ++

E0616, field price_cents of struct Item is private. The help notes that a method with that name exists and suggests calling it. That method is pub(crate), which brings us to the middle level of visibility.

Visibility levels

Written asVisible to
nothingthe current module and its children
pub(self)same as nothing
pub(super)the parent module and everything below it
pub(in path)a named ancestor module
pub(crate)everything in this crate, and nobody outside it
pubanyone who can reach the module

pub(crate) is the level most application code needs. price_cents() is read by the billing module, which lives elsewhere in the same crate, but it is not something a dependent crate should see if this code is ever published as a library. When you are writing a library, pub is your public API and every pub(crate) is room to change without breaking users; designing a public API develops that idea.

One rule runs in only one direction. A child module can use its parent's private items, because it is "inside" the parent. The parent cannot use the child's private items. billing/mod.rs shows both:

src/billing/mod.rsrust
pub mod invoice;

// A sibling module can reach up with `super::`, and down with a relative path.
fn tax_rate_percent() -> u32 {
    8
}

pub(super) fn tax_for(subtotal: u32) -> u32 {
    subtotal * tax_rate_percent() / 100
}

tax_rate_percent is private to billing, and tax_for is pub(super), visible to billing's parent and below. The child module invoice calls super::tax_for, and it could equally have called the private super::tax_rate_percent(); the compiler accepts that, because a child may see its ancestors' private items. Here is the child:

src/billing/invoice.rsrust
use crate::inventory::Item;

pub struct Invoice {
    lines: Vec<(String, u32, u32)>,
}

impl Invoice {
    pub fn new() -> Invoice {
        Invoice { lines: Vec::new() }
    }

    pub fn add(&mut self, item: &Item, qty: u32) {
        self.lines
            .push((item.name.clone(), item.price_cents(), qty));
    }

    pub fn summary(&self) -> String {
        let subtotal: u32 = self.lines.iter().map(|(_, price, qty)| price * qty).sum();
        let tax = super::tax_for(subtotal);
        format!(
            "subtotal {} cents + tax {} cents = {} cents",
            subtotal,
            tax,
            subtotal + tax
        )
    }
}

Two path words appear. crate::inventory::Item starts at the root of the crate, so it works from anywhere. super::tax_for starts one module up. A path with neither is relative to the current module. Absolute crate:: paths survive moving a file; super:: paths survive moving the whole subtree. Choose crate:: for the cross-module imports and super:: for talking to your parent.

Two path errors

A use with a mistake in the path fails with a different code than a privacy error. Changing the import in main to use billing::invoices::Invoice; gives:

Text
error[E0432]: unresolved import `billing::invoices`
 --> src/main.rs:4:14
  |
4 | use billing::invoices::Invoice;
  |              ^^^^^^^^ could not find `invoices` in `billing`

For more information about this error, try `rustc --explain E0432`.
error: could not compile `shop` (bin "shop") due to 1 previous error

E0432, unresolved import: billing has no module invoices. The module is invoice, singular. Typos like this are the most common cause, followed by forgetting the mod declaration, in which case the compiler says the module does not exist at all. The second kind of error is a name used without being brought into scope. Delete the use inventory::Item; line and keep the rest:

Text
error[E0433]: cannot find type `Item` in this scope
 --> src/main.rs:8:16
  |
8 |     let item = Item::new("keyboard", 4999, 3);
  |                ^^^^ use of undeclared type `Item`
  |
help: consider importing this struct
  |
4 + use crate::inventory::Item;
  |

With the use inventory::Item; line removed, Item::new has nothing to refer to, and E0433 explains: use of undeclared type Item, with a suggested import. The suggestion even includes the full crate::inventory::Item. Rust's diagnostics for missing imports are good enough that you can usually accept the suggestion as written.

What mod and use do, side by side

mod name;use path::Item;
Loads a fileyesno
Creates a moduleyesno
Shortens a pathnoyes
Appears once per moduleyesas often as needed
Can be pubyes, to export the moduleyes, pub use re-exports an item

pub use is the tool for presenting a clean public API: define things deep in the tree for your own convenience and re-export the ones users need at the top, so crate::Item works even though the type lives in crate::inventory. Crates, packages and modules puts modules in context with crates and packages.

Habits

  • Start with everything private and add the narrowest pub that compiles.
  • One file per module is fine until a file passes a few hundred lines; then split it.
  • Prefer mod.rs-free naming (billing.rs beside a billing/ folder) in new code if your team agrees; both work, and mixing them in one crate is the thing to avoid.
  • Group imports at the top of the file and let the formatter sort them.
  • Treat a pub item in a library as a commitment and a pub(crate) item as an implementation detail.

When the module system is not the answer

Modules organise names inside one crate. If you want to compile parts separately, publish them separately or enforce a dependency direction between teams, you want multiple crates in a workspace, where the visibility boundary is enforced by the build instead of by convention. And if a single file starts with fifteen mod blocks nested inline, the structure is hiding in indentation instead of in the file tree; moving them to files makes the layout visible to anyone who opens the directory.

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.