Reading Real Code
Mental model: You do not read a repository like a book, front to back. You find the entry point, follow one piece of data through it, and ignore everything that piece of data never touches.
Level: beginner · about 16 minutes
Every job you will ever have starts the same way: someone hands you 40,000 lines you did not write and asks for a small change. You will spend far more of your career reading code than writing it, and nobody teaches reading. This lesson is the method, and it is boring on purpose. Boring methods work when you are lost.
Step 1: find the entry point
- Open
package.json. Readscriptsfirst:dev,build,testandstarttell you how the project runs, which tool builds it, and which files those tools point at. - Read
main,module,exportsor the HTML file the dev server serves. That is the front door. - Read
dependencies(notdevDependencies). A framework, a router, a state library and a date library tell you the architecture before you read any code. - Open the
README, but treat it as a historical document. Trustpackage.jsonover prose. - Look at the top-level folder names.
src/,routes/,components/,lib/,api/are a map of the team's mental model.
Step 2: skim a file by its exports
Do not start at line 1 and read down. Read the imports at the top and the exports anywhere, then stop. The imports tell you what this file depends on, and the exports tell you what other files are allowed to ask of it. That is the whole contract, and it usually fits in ten lines.
// src/cart/cart-store.js
import { computeTax } from '../pricing/tax.js'; // 1. what it needs
import { save, load } from '../storage/local.js';
const KEY = 'cart:v2'; // 2. module-level state
let items = load(KEY) ?? [];
export function addItem(product, qty = 1) { /* 40 lines */ }
export function removeItem(id) { /* 12 lines */ }
export function totals() { /* 25 lines */ } // 3. the public surface
export function subscribe(fn) { /* 8 lines */ }
function normalise(product) { /* not exported: an internal detail */ }The two-minute skim: read only the highlighted lines, in this order.
From those four export names you already know this file owns the cart, keeps it in local storage, computes totals with tax from elsewhere, and lets other code subscribe to changes. You have not read one function body. If your task is "the tax is wrong on the totals line", you now know exactly which two files to open.
Step 3: follow one piece of data
// ---------- src/main.js (entry point, from package.json scripts.dev)
import { mountCart } from './ui/cart-view.js';
mountCart(document.querySelector('#cart'));
// ---------- src/ui/cart-view.js
import { totals } from '../cart/cart-store.js';
export function mountCart(root) {
const { gross } = totals(); // <- where the number comes from
root.textContent = format(gross);
}
const format = (n) => '$' + n.toFixed(2);
// ---------- src/cart/cart-store.js
import { computeTax } from '../pricing/tax.js';
let items = [{ price: 10, qty: 2, region: 'EU' }];
export function totals() {
const net = items.reduce((sum, i) => sum + i.price * i.qty, 0);
const tax = computeTax(net, items[0].region); // <- suspicious: items[0]
return { net, tax, gross: net + tax };
}
// ---------- src/pricing/tax.js
const RATES = { EU: 0.2, US: 0.07 };
export function computeTax(net, region) {
return net * (RATES[region] ?? 0);
}Pick one value you can see on screen and trace it from input to display. Here a price shows the wrong tax. Read the four files in the order the data moves, not the order they are listed.
The trace took four hops and found a real bug without a debugger: the region is taken from the first item, so a cart mixing EU and US items taxes everything at the first region it happens to see. Notice what you did not read: styling, build config, tests, the other 90 files. Following data is what makes a large repository finite.
const RATES = { EU: 0.2, US: 0.07 };
const computeTax = (net, region) => net * (RATES[region] ?? 0);
const items = [
{ price: 10, qty: 2, region: 'EU' },
{ price: 100, qty: 1, region: 'US' },
];
function totalsBuggy() {
const net = items.reduce((sum, i) => sum + i.price * i.qty, 0);
return { net, tax: computeTax(net, items[0].region) };
}
function totalsFixed() {
const net = items.reduce((sum, i) => sum + i.price * i.qty, 0);
const tax = items.reduce((sum, i) => sum + computeTax(i.price * i.qty, i.region), 0);
return { net, tax };
}
console.log(totalsBuggy()); // -> { net: 120, tax: 24 } every item taxed at 20%
console.log(totalsFixed()); // -> { net: 120, tax: 11 } 4 + 7The same four files collapsed into one runnable slice, so you can watch the bug happen.
Naming conventions are documentation
| Pattern you see | What it usually means | Where to look next |
|---|---|---|
use prefix (useCart) | a hook: stateful logic for one UI framework | the component that calls it |
get / fetch prefix | get is cheap and local, fetch crosses the network | the API layer |
*.service.js, api/ | the only place allowed to talk to the server | a single HTTP client file |
utils/, lib/, helpers/ | pure functions, no app knowledge, safe to read alone | nothing, they are leaves |
*.store.js, store/ | shared mutable state and its subscribers | who calls subscribe |
index.js in a folder | the folder's public surface, re-exporting the rest | read it first, it is a summary |
_private, #field, no export | an internal detail, free to change | skip on the first pass |
Reading a stack trace backwards
TypeError: Cannot read properties of undefined (reading 'region')
at computeTax (src/pricing/tax.js:4:29) <- where it BROKE
at totals (src/cart/cart-store.js:8:15)
at mountCart (src/ui/cart-view.js:4:22) <- your first frame in the chain
at node_modules/framework/dist/render.js:110:7 <- not your code, skip
at main (src/main.js:2:1) <- where it STARTED
- Read the message, precisely Not "undefined error". It says reading
regionofundefined, so something that should have been an object was missing. The property name is the clue. - Open the top frame at that exact line and column Line 4, column 29. Look at what is being read there, and ask which of the values on that line could be undefined.
- Walk down to your first frame Skip
node_modulesframes. The first frame in your ownsrc/is where the bad value was created or passed. - Reproduce the input, then fix Empty cart. Now you can write a failing test before touching the code, which is how you know the fix worked.
function load() { return inner(); }
function inner() { return JSON.parse('{oops'); }
try {
load();
} catch (err) {
const frames = err.stack.split('\n').length;
console.log(err.name, frames > 1);
}JSON.parse throws a SyntaxError, and err.stack is a multi-line string whose first line is the message and whose remaining lines are frames: inner, then load, then the top level. The error type plus the frame list is almost always enough to locate a cause without a debugger.
Try it yourself
Skim a file by its exports
const source = `
import { computeTax } from '../pricing/tax.js';
import { save, load } from '../storage/local.js';
const KEY = 'cart:v2';
export function addItem(product, qty = 1) {}
export function removeItem(id) {}
export function totals() {}
function normalise(product) {}
`;
function exportedNames(src) {
const names = [];
for (const line of src.split('\n')) {
const match = /^export\s+(?:async\s+)?function\s+(\w+)/.exec(line.trim());
if (match) names.push(match[1]);
}
return names;
}
console.log(exportedNames(source)); // -> [ 'addItem', 'removeItem', 'totals' ]
Add detection for export default and for export const name = .... Then make it report the imports too, so you can see a file's contract from both sides.
Follow one value through four files
const files = {
'src/main.js': ['ui/cart-view.js'],
'src/ui/cart-view.js': ['cart/cart-store.js'],
'src/cart/cart-store.js': ['pricing/tax.js'],
'src/pricing/tax.js': [],
};
function pathFrom(entry, graph, seen = []) {
seen.push(entry);
const next = graph[entry]?.[0];
if (!next) return seen;
const full = 'src/' + next;
return graph[full] ? pathFrom(full, graph, seen) : seen;
}
console.log(pathFrom('src/main.js', files).join(' -> '));
// -> src/main.js -> src/ui/cart-view.js -> src/cart/cart-store.js -> src/pricing/tax.js
Change the second item to region EU and rerun. Then fix totals so each item is taxed at its own rate, and watch which numbers move.
Exercises
Order a module graph
A module graph maps a file name to the files it imports: { 'a.js': ['b.js'], 'b.js': [] }. Write dependencyOrder(graph, entry) returning every module reachable from entry, deepest dependency first and entry last. Visit each module once, and do not hang on a circular import.
Parse a stack trace
Write parseStack(text) turning a stack trace into an array of { fn, file, line } objects, top frame first, skipping the message line and any frame without a file:line:column. Then write firstAppFrame(frames) returning the first frame whose file starts with src/, or null if there is none. Frames look like at name (src/a.js:4:29) or at src/a.js:4:29.
Check yourself
- You are handed an unfamiliar repository and one bug report. What do you open first?
package.json, to find the scripts, entry point and real dependencies —package.jsontells you how the project starts, which tool builds it and what it depends on, in about twenty lines. Running the tests is a good second move, but until you know the entry point you cannot tell which of 400 files the bug can possibly live in.- In this trace, which file do you open first?
src/ui/table.js, the first frame that is your own code — The library noticed the problem, but it did not create it: something passed a non-array where the library expected an array. The first frame in your own code is where that value came from, sosrc/ui/table.js:31is the line to read. Reading the top frame first is the most common wasted hour in debugging.- What does this print?
c.js,b.js,a.js— Each module is pushed only after its own imports, and theseenset stopsc.jsbeing recorded twice. Soc.js(no imports) lands first, thenb.js, then the entrya.js. That post-order walk is exactly how a bundler decides evaluation order.- Why read a file's exports before its function bodies?
- The exports are the contract other files depend on, and they fit on one screen — Exports are the only part of a file other files can touch, so they define what the file is for. Everything unexported is an internal detail you can skip on a first pass. It is not dead code (it is called from inside the file), and export order says nothing about runtime order.
Common mistakes
- Reading a repository file by file, alphabetically, instead of following one piece of data through it.
- Opening the top stack frame first when it is inside
node_modules, then reading a library you did not write. - Trusting the README over
package.json. Prose rots, scripts are executed. - Searching for concepts ("cart total logic") instead of literals ("Add to cart",
computeTax). - Assuming a short async stack means the error came from nowhere. It means something scheduled the callback and already returned.
- Trying to understand a whole file before making a small change. Understand the path your change touches.
Takeaways
- Find the entry point from
package.json, then let the imports turn a pile of files into a tree. - Skim a file by its imports and exports first. That is its contract, and it is ten lines, not four hundred.
- Follow one concrete value from input to display, and ignore every file it never touches.
- A stack trace is newest-first. The top frame is where it broke, your first frame is usually why.
- Naming conventions are documentation:
use*,api/,utils/andindex.jsall tell you where to look next. - Search for literal strings the user can see. It is the fastest jump from a bug report into the code.