Viscosity
Review
Part IX · Rust for TypeScript developers · Day 34

135. Modules, crates and visibility

In JavaScript a file is a module and export opens the door. Rust builds the module tree on purpose, keeps everything private unless you write pub, and lets a struct guard its own invariants. Then a workspace stacks crates into a whole compiler.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • Declare modules inline and in files, and refer to items with use, crate:: and super::.
  • Explain Rust's privacy rule, including private fields in public structs, and read E0603, E0616 and E0624.
  • Re-export items with pub use to shape a crate's public face.
  • Describe drip-rs's module tree and engine/rust's workspace of six crates.

Nine files, one line each

Here is the top of drip-rs’s src/lib.rs, the root of the drip library crate:

pub mod chunk;
pub mod compiler;
pub mod debug;
pub mod gc;
pub mod object;
pub mod scanner;
pub mod table;
pub mod value;
pub mod vm;

pub use vm::{Options, Outcome, Vm};

In chapter 12 each JavaScript file was a module just by existing, and import { scan } from './scanner.js' found it by path. Rust works the other way round. A file is not compiled until a parent says it should be: mod scanner; tells the compiler “there is a module called scanner; its code is in scanner.rs next to me”. Delete that line and scanner.rs is ignored, however much code is in it.

Intuition

A JavaScript project is a pile of files that point at each other. A Rust crate is a family tree drawn from the top: the root names its children, each child names its own, and a file nobody names isn’t in the family at all.

The module tree

A module can also be written inline, with a body in braces. You have seen one in every Rust exercise: the tests are wrapped in mod tests { use super::*; ... }. Inline or in a file, the result is the same tree, rooted at crate:

crate                 src/lib.rs
├── chunk             src/chunk.rs
├── compiler          src/compiler.rs
├── scanner           src/scanner.rs
│   └── tests         mod tests { } inside
└── ...

Items are named by paths through the tree. crate::scanner::Token starts at the root; super:: means the parent module, which is how a tests module reaches the code above it; self:: is the current one. use brings a path into scope under a short name, like import. drip-rs’s compiler opens with:

use crate::chunk::{Chunk, OpCode};
use crate::gc::{Heap, Trace};
use crate::scanner::{Scanner, Token};
Definition

A module is a named scope in a crate’s module tree, declared with mod name { ... } or with mod name;, whose body is then read from name.rs (or name/mod.rs). Items are reached by paths (crate::a::b, super::x, self::y), and use path; binds a path to a short name in the current module[1].

Private by default

In chapter 12, names were private unless exported. Rust’s default is the same, with one more level: a struct can be public while its fields stay private. Here is drip-rs’s hash table (table.rs, trimmed):

pub struct Table {
    count: usize,
    keys: Vec<Option<StrRef>>,
    values: Vec<Value>,
}

impl Table {
    pub fn new() -> Self { ... }
    pub fn get(&self, key: StrRef)
        -> Option<Value> { ... }
    // no pub: private
    fn grow(&mut self, capacity: usize) {
        ...
    }
}

Other modules may name Table and call new and get. They can’t touch count, keys or values, and they can’t call grow. Try both on a small copy of this table, from outside its module, and Rust 1.97 says (trimmed):

error[E0624]: method `grow` is private
 9 |  fn grow(&mut self, cap: usize) {}
   |  ------------------------------
   |  private method defined here
...
15 |     t.grow(16);

error[E0616]: field `count` of struct
  `Table` is private
16 | println!("{}", t.count);
   |                  ^^^^^ private field

A private free function or module named by a path, such as table::helper(), gives a third code, E0603 (“function helper is private”).

Why bother? count counts live entries and tombstones, and the two columns must always have the same length. If any module could write t.count = 0 or push onto keys alone, the table could be broken from anywhere. With private fields, only table.rs can break it, so only table.rs has to be read to trust it. Because outsiders can’t fill in private fields, they also can’t build a Table { ... } literal: they must go through new.

The exact rule, from the Rust Reference: a private item may be used by the module it is in and that module’s descendants; a public one by anyone who can reach all its ancestor modules[2]. That second half is why a tests child module can test private functions, and why pub on an item inside a private module doesn’t make it visible outside the crate. Two exceptions: the variants of a pub enum are all public, and so are the items of a pub trait. For in-between cases there is pub(crate), visible anywhere in this crate but not outside; neither drip-rs nor engine/rust uses it.

Definition

Visibility says where a name may be used. Items, fields and methods are private by default: usable only in their own module and its descendants. pub makes them usable wherever their parent modules are reachable; pub(crate) limits that to the current crate. pub use re-exports an item under a new path.

The public face

The last line of lib.rs, pub use vm::{Options, Outcome, Vm};, is a re-export. The VM really lives at drip::vm::Vm, but users can write drip::Vm. drip-rs’s main.rs is a separate crate, the drip-rs binary (chapter 121), and it sees only what the library made pub:

use drip::compiler::compile;
use drip::gc::Heap;
use drip::{Options, Vm};

Like a package’s exports in npm, the set of pub paths is a crate’s public API, and changing it is what breaks other people’s code.

Workspaces

drip-rs is one package holding two crates. The Rust Viscosity is bigger: six packages, each with its own Cargo.toml, built together as one workspace. Its root Cargo.toml has no code, only this (trimmed):

[workspace]
members = ["crates/vir", "crates/core",
    "crates/frontend-js",
    "crates/frontend-python",
    "crates/cli", "crates/wasm"]

[workspace.dependencies]
viscosity-vir = { path = "crates/vir",
    version = "0.1.0" }
oxc_parser = "0.156"

(The real file keeps each entry on one line.) A member then writes viscosity-vir.workspace = true instead of a path or a version, so every crate agrees on one version of oxc. The diagram leaves out frontend-python, chapter 187’s Python plugin, which sits beside frontend-js; the five here are the ones Part IX uses. A package named viscosity-core is viscosity_core in Rust code, since - can’t appear in a path. Step through what depends on what:

Step throughThe crates of engine/rust
Five of the six crates in a workspace, with arrows from each crate to the crates it depends onworkspace engine/rust · one Cargo.lock · one target/viscosity-virviscosity-coreviscosity-frontend-jsviscosity-cliviscosity-wasm
One workspace, listed in engine/rust/Cargo.toml as members: five of its six crates are drawn (the sixth, frontend-python, is chapter 187’s Python plugin). They share one Cargo.lock and one target/ folder, and cargo test --workspace builds and tests them all.
1/6
Definition

A workspace is a set of packages built together from one root Cargo.toml with a [workspace] table. Its members share one Cargo.lock and one output folder, target/[1], and depend on each other with path dependencies. engine/rust’s README tests them all with cargo test --workspace.

Under the hood

The split follows the TypeScript engine’s packages, and the README maps each Rust module to one TypeScript file (plan.ts to core::plan, lower.ts to frontend_js::lower). Crate boundaries turn chapter 90’s design into something the compiler checks: core doesn’t depend on frontend-js, so its library code can’t mention oxc or JavaScript syntax, only VIR and its own LanguagePlugin trait. Its tests do use the JavaScript plugin, through a [dev-dependencies] entry: those are built only for tests, so they don’t count as a cycle (a normal dependency from core to frontend-js would, and cargo rejects it with “cyclic package dependency”). The README says the analyses contain no JavaScript-specific code; the dependency graph is what keeps it that way. And crates are cargo’s unit of compilation, so the command-line tool and the WebAssembly build each add only a thin crate over the same core.

Your turn

ExerciseA store that keeps its promise

The soa module holds particles as two columns, xs and vxs, that must always be the same length. As given, the tests can’t even compile: nothing is public. Make exactly the API public (the struct, new, push, len, x and step), and keep the columns private so no one outside can push to just one. Then fix step, which should move each particle by vx * dt.

mod soa {
    // Invariant: xs and vxs always
    // have the same length.
    struct Particles {
        xs: Vec<f64>,
        vxs: Vec<f64>,
    }

    impl Particles {
        fn new() -> Self {
            Particles {
                xs: Vec::new(),
                vxs: Vec::new(),
            }
        }

        fn push(&mut self, x: f64,
            vx: f64) {
            self.xs.push(x);
            self.vxs.push(vx);
        }

        fn len(&self) -> usize {
            self.xs.len()
        }

        fn x(&self, i: usize)
            -> f64 {
            self.xs[i]
        }

        fn step(&mut self,
            dt: f64) {
            let vs = &self.vxs;
            let xs = self.xs.iter_mut();
            for (x, v) in xs.zip(vs) {
                *x += v;
            }
        }
    }
}
Open in Rust Playground ↗

What you learned

  • A crate is a module tree rooted at crate. mod name; pulls in name.rs; an unnamed file isn’t compiled.
  • Paths name items: crate::, super::, self::. use shortens them.
  • Everything is private by default, fields included. Private means “this module and its descendants”; pub opens it up, pub use re-exports.
  • Private fields let a type guard its invariants, like drip-rs’s Table.
  • A workspace builds several crates together, with one lock file and one target/. engine/rust’s six crates make the plugin boundary a compile-time fact.

Check your understanding

0/6
  1. 1.

    You add src/table.rs to a crate but never write mod table;. What happens?

  2. 2.

    In pub struct Table { count: usize, pub keys: Vec<u32> }, what can another module do?

  3. 3.

    A private function is defined in module a. Where can it be called?

  4. 4.

    What does pub use vm::{Options, Outcome, Vm}; in drip-rs’s lib.rs do?

  5. 5.

    What do the six crates in engine/rust’s workspace share?

  6. 6.

    viscosity-core’s tests use viscosity-frontend-js, which depends on core. How is that allowed?

References

  1. [1] Steve Klabnik, Carol Nichols, Chris Krycho (2026). The Rust Programming Language (3rd edition). No Starch Press; free online at doc.rust-lang.org/book. doc.rust-lang.org/book/ (checked 2026-10-31)
  2. [2] The Rust Project Developers. The Rust Reference: Visibility and privacy. The Rust Reference (rust-lang/reference on GitHub, rendered at doc.rust-lang.org/reference). doc.rust-lang.org/reference/visibility-and-privacy.html (checked 2026-10-31)