01 / The idea
Exporting the array is a fair first version.
You’re building a book-tracking page. The reading queue needs to add a book, remove one, and
tidy the title someone typed into a form. So the file exports what that takes: an entries array, a normalizeTitle helper, and addEntry and removeEntry, which skip a book already in the queue.
One caller, four names, nothing to explain.
Read the first versionTypeScript · the code this lesson starts from
export const entries: QueueEntry[] = [];
export function normalizeTitle(title: string): string {
return title.trim().replace(/\s+/g, ' ');
}
export function addEntry(entry: QueueEntry): void {
if (!entries.some((item) => item.id === entry.id)) {
entries.push({ ...entry, title: normalizeTitle(entry.title) });
}
}
export function removeEntry(id: string): boolean {
const index = entries.findIndex((entry) => entry.id === id);
if (index < 0) return false;
entries.splice(index, 1);
return true;
} The Go version has the same four names, capitalized, so every one of them is visible outside the package. Both languages meet again at the queue’s surface in section 02.
Then a second caller arrives, a “Want to read” button on the book page, and it pushes
straight into entries. The duplicate check lives in addEntry, so
the button skips it and the same book shows up twice. Meanwhile a test imports normalizeTitle to check title cleaning, so changing how titles are cleaned now means
editing code that was never meant to be a queue client.
A module is a piece of code with a public surface you choose and an implementation nothing outside can name. The queue exports three jobs, add, remove, and list. The array, the cleaning rule, and the duplicate check stay inside, where every caller has to go through them.
The file or package draws the line, but you decide where. Section 05 moves the queue into a page where three components share it.
02 / See the shape
Export the jobs. Keep the storage.
Basic form exports createReadingQueue and the ReadingQueue contract; the array and the cleaning helper live inside it. Switch
to In the wild for the page’s own helpers: a form adapter, a title list,
and a message for each decision, none of which can reach the stored entries. At the call site uses only the returned queue, and edits a listed title to show
the queue keeps its own.
TypeScript draws the line with export at the file; Go draws it with a capital letter
at the package. Both print the same run.
The module exposes a queue contract and factory; storage and its cleaning helper stay inside the implementation.
export type AddResult = 'added' | 'duplicate' | 'invalid';
export type ReadingQueue = Readonly<{
add(id: string, title: string, author: string): AddResult;
remove(id: string): boolean;
list(): readonly QueueEntry[];
}>;
// The queue owns its storage. Only these named operations cross the module boundary.
export function createReadingQueue(): ReadingQueue {
const stored: QueueEntry[] = [];
function clean(value: string): string {
return value.trim().replace(/\s+/g, ' ');
}
function add(id: string, title: string, author: string): AddResult {
const cleanId = clean(id);
const cleanTitle = clean(title);
const cleanAuthor = clean(author);
if (!cleanId || !cleanTitle || !cleanAuthor) return 'invalid';
if (stored.some((entry) => entry.id === cleanId)) return 'duplicate';
stored.push({ id: cleanId, title: cleanTitle, author: cleanAuthor });
return 'added';
}
function remove(id: string): boolean {
const index = stored.findIndex((entry) => entry.id === clean(id));
if (index < 0) return false;
stored.splice(index, 1);
return true;
}
function list(): readonly QueueEntry[] {
return stored.map((entry) => ({ ...entry }));
}
return { add, remove, list };
} type ReadingQueue interface {
Add(id, title, author string) AddResult
Remove(id string) bool
List() []Entry
}
type queue struct {
entries []Entry
}
func NewReadingQueue() ReadingQueue {
return &queue{}
}
func clean(value string) string {
return strings.Join(strings.Fields(value), " ")
}
func (q *queue) Add(id, title, author string) AddResult {
id, title, author = clean(id), clean(title), clean(author)
if id == "" || title == "" || author == "" {
return Invalid
}
for _, entry := range q.entries {
if entry.ID == id {
return Duplicate
}
}
q.entries = append(q.entries, Entry{ID: id, Title: title, Author: author})
return Added
}
func (q *queue) Remove(id string) bool {
id = clean(id)
for index, entry := range q.entries {
if entry.ID == id {
q.entries = append(q.entries[:index], q.entries[index+1:]...)
return true
}
}
return false
}
func (q *queue) List() []Entry {
entries := make([]Entry, len(q.entries))
copy(entries, q.entries)
return entries
} Reading the TypeScriptExports and file-private names
stored, clean, and the inner functions are not exported, and
they live inside createReadingQueue, so an importer cannot name them. The
queue can swap the array for IndexedDB or change the cleaning rule without touching a
caller.
list returns new entry objects in a new array. The readonly in its type stops a TypeScript caller from editing them at compile time;
the copy covers a cast, a plain JavaScript caller, and anything that reaches the objects at
runtime. Section 04 is about that copy.
Reading the GoCapitalization is visibility
Go exports identifiers that start with a capital letter. queue and clean stay inside the package, while NewReadingQueue, Entry, and the ReadingQueue interface are its public vocabulary.
Go has no read-only slice, so List copies into a new slice. Because Entry holds only strings, copy duplicates each value and the caller
can’t reach the stored ones. The interface is a convenience here; the lowercase names are
what keep callers out.
03 / Watch the boundary
The surface shrinks and the queue still does its work.
Five steps, each running the lesson’s TypeScript. The left side of the board shows the names a caller can use; the right side shows what stays inside. Before each step, guess whether the caller can get around the duplicate check.
In Try it, pick a surface and add the same title twice.
What should a caller be allowed to know?
Everything is available to every caller. Exported: entries, normalizeTitle, addEntry, removeEntry. Private: none. addEntry({ id: 'book-0', ... }) → adds one item; entries.push(...) → also allowed; normalizeTitle(' a title ') → also allowed. Nothing inside the file is private. A caller can bypass duplicate checks, storage rules, or formatting.
No boundary yet.
Exported helpers and exported storage let every caller depend on every detail.
Reduced motion: choose a scene to see its completed state.
Read this scene
Exported helpers and exported storage let every caller depend on every detail.
Everything is available to every caller. Exported: entries, normalizeTitle, addEntry, removeEntry. Private: none. addEntry({ id: 'book-0', ... }) → adds one item; entries.push(...) → also allowed; normalizeTitle(' a title ') → also allowed. Nothing inside the file is private. A caller can bypass duplicate checks, storage rules, or formatting.
Watch restarts when you return. Try it starts with a fresh queue.
What the small surface buys you
Now put names on what you just watched. These are the words you’ll hear in a design review, and each one points at the queue’s code.
- A public API
add,remove, andlistare all a caller can ask for. In the first version,entries.pushwas part of the API whether anyone meant it or not.- One owner for the rules
addreturns'duplicate'for an id already stored and'invalid'for a blank title. The button can’t skip a check it can’t reach.- Information hiding
storedandcleancan change tomorrow. No caller named them, so none of them has to change.- Independent instances
- Each
createReadingQueue()call closes over its ownstored. The module decides what is visible; the factory decides how many queues exist. - Readable call sites
addFromForm(queue, form)reads as the job it does. Nobody reading it has to know whether the queue is an array.
None of that is free. Section 08 is the other side: copies, shared module state, and the names you now have to keep stable.
04 / Try a decision
A cheaper list() can hand the storage back.
A profile of the queue page shows list() copying every entry on every render.
Someone proposes a shorter body. The call site in section 02 does exactly what a careless
caller would: it edits a title in what list() returned, then reads the queue
again. TypeScript’s readonly stops that edit at compile time, but a cast or a
plain JavaScript caller gets through, and Go has no readonly at all.
05 / Give it a real job
Components ask the queue for work. They never reach into it.
On the real page, the queue is one per reader, not one per button. The book page’s “Want to read” button adds to it, the header badge shows how many books are waiting, and the queue page lists them with a Remove button each. All three import one file, and that file is the module.
Owns the queue
One queue, its rules, and the copy it hands out.
Name the jobs
Add a book, remove one, read the current list.
Render the answers
The button shows the add result; the badge and page show the list.
The example leaves out saving the queue to the reader’s account, syncing between tabs, and permissions. Each of those changes what happens inside the module; none of them changes the three names the components import.
Build UIs?Every component file is already a module, and one day a shared queue makes you write one on purpose.
Where it already is in your components
Every component is a module. A parent can pass only the props a child declares; the child’s local state and helper functions are invisible from outside. And you import small surfaces all day: a date formatter, an API client, a hook from a library, each hiding far more than it exports.
The textbook panes show the queue at that scale. A shortlist panel creates its own queue
and calls add from a click handler. React keeps the instance in useState, which holds it for the component’s whole life; Svelte’s <script> runs once per instance. The component never sees the array.
When you have to own it
Now the button, the badge, and the queue page have to agree. The quick fix is the lesson’s starting design again: export an array and let each component push to it. The badge doesn’t update when the button pushes, and the duplicate check lives in whichever component remembered it. So one file owns the queue and exports three things: add, remove, and a way to read the list.
In React, the way to read is a hook built on useSyncExternalStore. Its docs say the snapshot “must be immutable,” and a getSnapshot that
returns a new array on every call re-renders forever. So the module keeps one cached
snapshot and replaces it only when add or remove changes something.
The listeners and the snapshot stay private to the file.
In Svelte, the component can be the module. Code in <script module> “runs once when the module first evaluates, rather than for each component instance,” and its
exports become the file’s exports. The queue and its $state live there; any
file can import addToQueue, and nothing can import the queue. Only click
handlers change it, so a server render never writes to it. If the queue came from the
reader’s account, you would load it per request instead, for the reason in section 08.
A shortlist button creates its own queue and calls add. The component imports createReadingQueue from queue.ts, the basic form in section 02, and never receives the storage.
import { useState } from 'react';
import { createReadingQueue } from './queue';
export function ReadingQueueButton({
book
}: {
book: { id: string; title: string; author: string };
}) {
// useState keeps one queue for the component's life; useMemo may be recomputed.
const [queue] = useState(() => createReadingQueue());
const [status, setStatus] = useState('empty');
function add() {
setStatus(queue.add(book.id, book.title, book.author));
}
return (
<button type="button" onClick={add}>
Add to reading queue · {status}
</button>
);
}
06 / Recognize it elsewhere
Anything you import has a front door and a back room.
The reading queue is one example. Here are a few you use without calling them modules. For each, notice how much more is inside than you can name.
| Where you’ve seen it | What you can name | What stays inside |
|---|---|---|
| A date formatter | Intl.DateTimeFormat | Locale data, calendar rules, and the fallback when a locale is missing. |
| Your API client | getInvoices() | The base URL, the auth header, and the retry on a timeout. |
| A Go database handle | sql.Open, db.Query | The connection pool, the driver, and when connections are reused. |
| A child component | Its props | Local state, effects, and helper functions. |
Size isn’t the test. A module earns its line when there is a rule worth keeping in one place and a surface small enough to learn.
07 / Already in your toolbox
Packages and platforms already draw this line for you.
Three to look at. For each one, find what an importer can name and who decided.
Node.js · "exports" in package.json
Once a package lists its entry points, every other file in it is closed to importers.
Reaching for pkg/subpath.js throws ERR_PACKAGE_PATH_NOT_EXPORTED. It is the queue’s boundary at the size of a
package.
Go · internal/ packages
Code in or below a directory named internal can be imported only by code
under that directory’s parent. Capital letters make a name public; internal decides
who that public is.
Svelte · <script module>
Runs once per module rather than once per component, and whatever it exports becomes an export of the component file. Everything it doesn’t export is shared by every instance and hidden from every importer.
Look at script module ↗A useful counterexample: http.DefaultServeMuxAn exported, mutable package variable
Go’s net/http exports DefaultServeMux, a router any package can
add routes to. It is convenient, and it is the lesson’s first version at the scale of the
standard library: shared storage anyone can write.
The cost shows in net/http/pprof, which is
“typically only imported for the side effect of registering its HTTP handlers.” A blank
import adds profiling routes to every server that serves DefaultServeMux.
Servers that create their own http.NewServeMux() decide their routes in one place,
and the pprof docs say so: register its handlers “with the mux you are using.”
08 / The parts to watch
Hiding a name decides who depends on it. The rest is still yours.
A small surface settles who can touch the queue. What the queue costs, how long it lives, and who shares it are still decisions.
Module state lives as long as the module
A module’s top level runs once per process, so a mutable array there is shared by every importer. In the browser that is usually one reader, which is why section 05’s page-wide queue is fine. On a server it is every request from every user.
Keep per-request or per-user state out of module scope on the server: load it per request
and pass it down. The lesson’s createReadingQueue is a factory for the same
reason: each call gets its own stored, and ownership is visible at the call.
What you return can reopen the boundary
Returning the array, or a new array of the same objects, hands the storage back. Section 04 is that mistake. Copies cost time on every call, so if a profile says they matter, return frozen entries or cache one snapshot until something changes, as the React module does.
Every export is a promise
Once another file imports a name, changing it means changing that file too. An export added for one test becomes a name you keep stable. Export a name when a caller needs to depend on it, and keep helpers inside until then.
A good surface matches the work
If every caller needs a special case, the surface is hiding the wrong thing. A stable
value type, an error type, and a few operations can all belong in the contract. Grow the
surface when callers genuinely need a name; a single doEverything is not smaller,
only vaguer.
A file boundary is not an architecture boundary
A module hides names inside one program. It doesn’t give you transactions, a network contract, separate deployment, or ownership between teams. Those belong to the architecture lessons on module contracts and data ownership.
09 / Make the call
The next change shows which design was cheaper.
Give both designs a plausible change and follow the work it creates.
| The change | Exported array and helpers | A queue with three operations |
|---|---|---|
| A second component adds books | It has to call addEntry and not push. Nothing stops it. | add is the only way in, so the duplicate check always runs. |
| The queue moves to IndexedDB | Every file that reads entries changes. | The inside of createReadingQueue changes. Callers don’t. |
| The title-cleaning rule changes | Every importer of normalizeTitle sees it, including the test. | Only clean changes; tests go through add. |
| A one-off script imports twenty books | A loop over addEntry, and you’re done. | A factory and result codes for a script that runs once. |
| A page needs to sort the list for display | entries.sort() reorders the queue itself. | list() is read-only, so it calls toSorted() on its copy. The
queue’s order is unchanged. |
Reach for a module when a rule has to hold no matter who calls. The second caller is the moment: the duplicate check stops being a convention and becomes the only way in.
Keep the exported array when there is one caller and nothing to protect. A script or a component’s local state doesn’t need a surface; hiding it would only add names.
The question I’d leave beside the code is: which names should stay the same if the inside changes tomorrow, and who owns the state behind them?
10 / Take the idea with you
Explain the queue without saying “module.”
“The queue gives you three things: add, remove, and list. The array and the cleaning rule are inside, so the duplicate check runs for everyone, and list hands you a copy.” That tells a reviewer more than the word does. When the reviewer wants the word, it’s information hiding: callers depend on the jobs, not on how they’re done.
Before moving on, jot down why the “Want to read” button could add a book twice, why [...stored] isn’t a copy of the queue, and one import in your own code that reaches
into an array, a map, or a helper it shouldn’t know about. A shared store in your last app counts.
Connections to follow nextRelated lessons
- Closures and captured state explains where
storedlives: eachcreateReadingQueue()call closes over its own. - Copying, identity, and equality covers section 04’s trap: a new array of the same objects is a shallow copy.
- Singleton is the page-wide queue as a pattern, with the costs of one shared instance.
- Coupling and cohesion helps decide which code belongs behind one surface.
- Module contracts moves from one file’s exports to contracts between larger parts of an application.