The Fortress with a Locked Gate
You are building a library for a game engine. You define a Player struct with x, y, and health fields. You want other modules to move the player and read their health, but you refuse to let them set health to negative values directly. You mark the struct pub and move on.
A colleague tries to use your code from another file. They write player.health = -5000. The compiler yells. They complain your library is broken. You look at your code. The struct is pub. Why is the field inaccessible?
Because pub on a struct does not make the fields public. You exposed the type name, but the contents remain locked. In Rust, visibility is granular. You control exactly what leaks out of a module, down to individual fields. This granularity is what lets you build safe abstractions. If you expose fields directly, users can break your invariants. If you hide fields and expose methods, you control the rules.
Visibility is a Chain, Not a Switch
Rust's visibility model works like a chain of custody. An item is only visible if every link in the path from the user to the item is open.
Think of your crate as a building. Modules are rooms. Items are objects in the rooms. By default, every room is locked, and every object is hidden in a safe. The pub keyword unlocks a door or opens a safe.
If you put a pub object in a locked room, no one outside can see it. The object is visible to the room, but the room is invisible to the hallway. You must unlock the room too. This applies recursively. If you have mod a { mod b { pub fn f() {} } }, the function f is visible to b, but b is private to a, and a is private to the crate. The function is effectively hidden. You need pub mod a and pub mod b to expose f to the world.
This chain rule prevents accidental leaks. You can mark an item pub inside a private module to share it with the parent, without exposing it to the rest of the crate. It gives you fine-grained control over your API surface.
The Basics: pub on Items and Fields
The pub keyword applies to functions, structs, enums, modules, constants, and struct fields. Each application is independent.
/// A public function callable from anywhere.
pub fn calculate_damage(attack: u32, defense: u32) -> u32 {
// pub makes this function visible to parent modules and external crates.
(attack as i32 - defense as i32).max(0) as u32
}
/// A private struct. Invisible outside this module.
struct InternalCache {
data: Vec<u8>,
}
/// A public struct with a private field.
/// Users can see the type, but cannot access or set the field.
pub struct SecretKey {
key: [u8; 32], // Private field. Encapsulation protects the key material.
}
/// A public struct with a public field.
/// Users can read and write the field directly.
pub struct Point {
pub x: f64,
pub y: f64,
}
The convention in Rust is to keep fields private by default. Public fields are acceptable for simple data containers where there are no invariants to protect, like Point. For types that enforce rules, like SecretKey or a HashMap that maintains capacity, fields should be private.
How the Compiler Checks Visibility
When the compiler resolves a path, it checks visibility at each step. It does not just check the final item.
If you write crate::utils::helpers::do_work(), the compiler verifies:
- Is
utilsvisible fromcrate? - Is
helpersvisible fromutils? - Is
do_workvisible fromhelpers?
If any step fails, you get an error. The most common error is E0603. This error tells you exactly which link in the chain is broken.
error[E0603]: module `helpers` is private
--> src/lib.rs:10:20
|
10 | crate::utils::helpers::do_work();
| ^^^^^^^ private module
The compiler points to helpers because that module is missing pub. The function do_work might be pub, but it doesn't matter. The module containing it is locked.
Fix the chain by adding pub to the module, or by re-exporting the item at a higher level. Don't just add pub everywhere. Add it where the boundary actually needs to open.
Real Code: Protecting Invariants
Consider a Wallet type. The wallet holds a balance and a PIN. The balance can be read by anyone, but the PIN must be secret. The balance should only change through deposits or withdrawals, which validate the PIN.
If you make pin public, anyone can reset it. If you make balance public, anyone can set it to infinity. You need a mix of visibility levels.
mod wallet {
/// A wallet that requires a PIN for transactions.
pub struct Wallet {
/// The current balance. Readable by users.
pub balance: u64,
/// The PIN. Hidden from users to prevent tampering.
pin: u32,
}
impl Wallet {
/// Create a new wallet with a PIN.
/// Public constructor allows users to create wallets.
pub fn new(pin: u32) -> Self {
Self { balance: 0, pin }
}
/// Deposit funds. Validates PIN internally.
pub fn deposit(&mut self, amount: u64, pin: u32) -> Result<(), &'static str> {
if pin != self.pin {
return Err("Invalid PIN");
}
self.balance += amount;
Ok(())
}
/// Internal helper to verify PIN.
/// Private because it's an implementation detail.
fn verify_pin(&self, pin: u32) -> bool {
self.pin == pin
}
}
}
fn main() {
let mut w = wallet::Wallet::new(1234);
// w.balance is accessible because the field is pub.
println!("Balance: {}", w.balance);
// w.pin is inaccessible. The compiler rejects this with E0616.
// w.pin = 0; // Error: field `pin` of struct `Wallet` is private
// Users must go through the method.
w.deposit(100, 1234).unwrap();
}
The pin field is private. Users cannot read or write it. They can only interact with the wallet through deposit, which checks the PIN. This enforces the invariant that transactions require authentication.
If you tried to access w.pin, the compiler would reject it with E0616 (field is private). This error is a feature. It stops you from accidentally bypassing security checks.
Trust the borrow checker. It usually has a point. Treat visibility errors the same way. The compiler is protecting your invariants.
Pitfalls and Compiler Errors
Visibility errors often stem from mismatched expectations. Here are the common traps.
The Pub Struct Trap
You mark a struct pub but forget to mark the fields. Users can see the type, but they cannot construct it or access fields. They get E0451.
error[E0451]: fields `x` and `y` of struct `Point` are private
--> src/main.rs:10:20
|
10 | let p = Point { x: 1.0, y: 2.0 };
| ^ private field ^ private field
The fix is either to add pub to the fields, or to provide a public constructor. If the struct has invariants, provide a constructor and keep fields private. If it's just a data bag, add pub to the fields.
The Private Module Trap
You mark a function pub inside a module, but the module itself is private. The function is visible to the module, but not to the parent. You get E0603 when you try to use it from outside.
The fix is to mark the module pub, or to re-export the function. Re-exporting is often cleaner. It lets you keep the module structure internal while exposing a flat API.
Over-exposure
You mark everything pub to avoid errors. This works, but it destroys encapsulation. Users start depending on internal helpers. When you refactor, you break their code. You lose the ability to change implementation details.
The fix is to start private. Mark items pub only when you have a reason. Use pub(crate) for items shared within the crate but not part of the public API. This keeps your public surface small and stable.
Re-exporting with pub use
Re-exporting lets you change the visibility path of an item without moving it. You use pub use to bring an item into the current namespace and expose it.
mod internal {
pub fn helper() {
println!("Helper");
}
}
// Re-export helper at the crate root.
// Users can call crate::helper() instead of crate::internal::helper().
pub use internal::helper;
This is a standard convention for library crates. You organize code into modules for maintainability, but you re-export the public API at the root for usability. Users don't need to know about your internal folder structure. They just import what they need.
Re-exporting also helps with visibility chains. If you have a deep module hierarchy, you can re-export items at each level to flatten the path. This avoids long qualification paths like crate::a::b::c::d::Type.
Convention aside: Re-export types and functions, not modules. Re-exporting modules can confuse users about where items come from. Prefer pub use module::Item over pub mod module.
Decision: Choosing the Right Visibility
Visibility modifiers control exactly who can see an item. Rust provides several levels. Pick the right one based on your needs.
Use pub when you are building a library and want to expose an item to users of your crate, or when you are in a submodule and want to expose an item to the parent module which will then re-export it. This is the standard choice for your public API.
Use pub(crate) when you want to share code across modules within your own crate but hide it from external users. This is the default choice for internal helpers, test utilities, or shared types that shouldn't be part of the public API. It prevents accidental exposure while allowing internal reuse.
Use pub(super) when you need to expose an item only to the immediate parent module. This is useful for factory functions or internal constructors that the parent module uses to build public types. It keeps the item hidden from siblings and the rest of the crate.
Keep items private by default when the item is an implementation detail. If the item is a helper function, a cache, or a field that should only be modified through methods, leave it private. Privacy gives you the freedom to change the implementation later without breaking users.
Start private. Open doors only when you have a reason. A closed door is safer than an open one.