Appearance
Intention-Revealing Code
Code should reveal what it is doing and why, not how it works mechanically. One principle, two inseparable surfaces:
- Names reveal intent at the call site. The reader sees what is happening without descending into the function.
- Contents reveal intent at the read site. When the reader does descend, the body is built from other intent-revealing pieces.
The two surfaces constrain each other. A high-level name over a body of inline mechanism is a lie. Contents that read as intent require named helpers at the right level. Neither half stands alone.
The reading-experience test
When the abstraction level is right, code reads as prose. The reader's brain engages with what the program is doing.
When the abstraction level is too low, code reads as programming-language structure and syntax. The brain spends cycles on how — control flow, variable shapes, primitive operations — instead of on the problem.
Self-check while reading your own code: is your brain tracking what or how? Tracking how means the abstraction level needs to come up — either by lifting mechanism into a named helper or by renaming the function to match what its body actually does.
Names that reveal intent
A name announces what something is for, not what it is or how it works. The caller should be able to read the call site and understand the program without ever opening the function.
The examples in this section are pure renames — the body is identical before and after, only the identifier moves. They isolate the naming lever from the restructuring lever.
Mechanism → intent
The function does the same work; the name promises a different thing.
❌ DON'T — name reports the algorithm
js
function checkParentPidStatus(pid) {
try {
process.kill(pid, 0);
return false;
} catch {
return true;
}
}✅ DO — name reports the question being answered
js
function isAbandonedProcess(pid) {
try {
process.kill(pid, 0);
return false;
} catch {
return true;
}
}The body is byte-identical. The call site changes from "check parent pid status" (huh?) to "is abandoned process" (oh, that's what we're asking).
Shape or type → purpose
A variable named after its container tells you nothing about its role.
❌ DON'T — name reports the type
js
const arr = users.filter((user) => user.lastLoginAt < cutoff);
for (const obj of arr) {
sendReminder(obj);
}✅ DO — name reports the role
js
const dormantUsers = users.filter((user) => user.lastLoginAt < cutoff);
for (const user of dormantUsers) {
sendReminder(user);
}Same code. The reader now sees a population (dormantUsers) being iterated, not "an array of objects."
Generic verb-noun → specific intent
processData, handleResult, doStuff, getValue — these names are syntactic shapes, not communication.
❌ DON'T — generic verb-noun
js
function processData(records) {
return records
.filter((record) => record.amount > 0)
.reduce((sum, record) => sum + record.amount, 0);
}✅ DO — name says what the function answers
js
function sumPositiveAmounts(records) {
return records
.filter((record) => record.amount > 0)
.reduce((sum, record) => sum + record.amount, 0);
}If you can't think of a specific name, that often means the function is doing more than one thing — or the wrong thing. The rename pressure surfaces design problems.
Boolean-returning functions read as questions
A boolean function should ask a yes/no question at the call site.
❌ DON'T — name doesn't read as a question
js
if (userPermissions(user)) { ... }
if (checkValid(input)) { ... }
if (admin(user)) { ... }✅ DO — name reads as a yes/no question
js
if (hasAdminAccess(user)) { ... }
if (isValid(input)) { ... }
if (canEdit(user, document)) { ... }Use is, has, can, should, or a verb that completes the question. The call site reads as a sentence.
Parameters get the same treatment
Parameter names are read at the body's top edge — they're as load-bearing as function names.
❌ DON'T — shape names in the signature
js
function send(arr, obj, str) {
for (const item of arr) {
item.from = obj.email;
item.subject = str;
transport.send(item);
}
}✅ DO — role names in the signature
js
function send(messages, sender, subject) {
for (const message of messages) {
message.from = sender.email;
message.subject = subject;
transport.send(message);
}
}Contents that reveal intent
A function body should read as a sequence of intent-named steps. The reader who descends into the function still gets prose — just at a finer grain than the call site.
The examples in this section are pure restructurings — names don't change, only what lives inside the body. They isolate the contents lever from the naming lever.
Lifting inline mechanism into a named step
A line that reads as mechanism should become a call to a function whose name reads as intent. The mechanism moves to where it belongs — into a function that exists to do that mechanism.
❌ DON'T — inline mechanism mixed with intent
js
function sendInvoice(order) {
const total = order.items
.filter((item) => !item.refunded)
.reduce((sum, item) => sum + item.price * item.qty, 0);
const tax = total * order.taxRate;
const due = total + tax;
emailer.send({
to: order.customer.email,
template: "invoice",
data: { total, tax, due },
});
}The reader's brain has to track two altitudes: "compute the totals" (prose) and "filter-then-reduce with these specific accessors" (mechanism).
✅ DO — body reads as prose, mechanism pushed into a named step
js
function sendInvoice(order) {
const totals = computeTotals(order);
emailer.send({
to: order.customer.email,
template: "invoice",
data: totals,
});
}
function computeTotals(order) {
const subtotal = order.items
.filter((item) => !item.refunded)
.reduce((sum, item) => sum + item.price * item.qty, 0);
const tax = subtotal * order.taxRate;
const due = subtotal + tax;
return { subtotal, tax, due };
}sendInvoice is now two intent-named steps. computeTotals is allowed to talk about filters and reduces — that's its job. The altitudes are consistent within each body.
One altitude per body
When a body mixes high-level calls with inline low-level work, either lift the low-level work into a helper or inline the high-level call — do not leave the body straddling two levels.
❌ DON'T — mixed altitudes
js
function deployRelease(version) {
validateRelease(version);
uploadArtifacts(version);
const tag = `release-${version}-${Date.now()}`;
exec(`git tag ${tag}`);
exec(`git push origin ${tag}`);
notifySlack(version);
}The middle three lines drop the reader into git-and-exec land between two intent-named calls. The body is reading at two altitudes at once.
✅ DO — every line at the same altitude
js
function deployRelease(version) {
validateRelease(version);
uploadArtifacts(version);
tagRelease(version);
notifySlack(version);
}
function tagRelease(version) {
const tag = `release-${version}-${Date.now()}`;
exec(`git tag ${tag}`);
exec(`git push origin ${tag}`);
}The reader of deployRelease sees the four steps. The reader of tagRelease sees git commands. Each function is honest about its altitude.
Hand-rolled state machines hide intent
Buffer-and-flush loops are a common source of mixed-altitude bodies. The intent ("group consecutive matching items") gets buried under mechanism (a running buffer, an EOF tail, an index variable).
❌ DON'T — buffer-and-flush mechanism inline
js
function findParagraphs(lines) {
const paragraphs = [];
let current = [];
for (const line of lines) {
if (line.trim() !== "") {
current.push(line);
} else {
if (current.length > 0) {
paragraphs.push(current);
current = [];
}
}
}
if (current.length > 0) {
paragraphs.push(current);
}
return paragraphs;
}The reader's brain is on the state machine: track the buffer, branch on emptiness, remember the EOF tail.
✅ DO — intent named, mechanism pushed into a function whose job is to be a state machine
js
function findParagraphs(lines) {
return groupConsecutive(lines, (line) => line.trim() !== "");
}
function groupConsecutive(items, predicate) {
const groups = [];
let open = null;
for (const item of items) {
if (predicate(item)) {
open ??= [];
open.push(item);
} else if (open) {
groups.push(open);
open = null;
}
}
if (open) groups.push(open);
return groups;
}findParagraphs now reads as its definition: paragraphs are groups of consecutive non-blank lines. The buffer-and-flush logic still exists, but it lives in groupConsecutive, where buffer-and-flush is what the function is. The mechanism is no longer hiding inside something that promised something else.
Both halves together: md.tables.js
Real code in this repo. skills/code-style/md/md.tables.js:70-101 checks markdown tables for formatting problems. The two halves move together — names and contents both change altitude.
The existing implementation is a hand-rolled buffer-and-flush state machine with shape-named loop variables and a duplicated edge case:
js
export function checkFile(filename) {
const content = readFileSync(filename, "utf8");
const lines = content.split("\n");
const errors = [];
let tableRows = [];
let tableStart = 0;
for (const [index, line] of lines.entries()) {
if (isTableRow(line)) {
if (tableRows.length === 0) {
tableStart = index + 1;
}
tableRows.push(line);
} else {
if (tableRows.length > 0) {
const error = checkTable(tableRows, tableStart, filename);
if (error) errors.push(error);
tableRows = [];
}
}
}
if (tableRows.length > 0) {
const error = checkTable(tableRows, tableStart, filename);
if (error) errors.push(error);
}
return errors;
}The reader's brain is on how: track the buffer, manage the start index, remember the tail at EOF, branch on every iteration. Nothing in the body says "the file contains tables; check each one."
Refactored to read as prose, with the same behavior:
js
export function checkFile(filename) {
const content = readFile(filename);
const tables = findTables(content);
return tables.flatMap(checkTable);
}
function readFile(filename) {
return readFileSync(filename, "utf8");
}
function findTables(content) {
const lines = content.split("\n");
return groupConsecutive(lines, isTableRow).map(toTable);
}
function toTable({ items, startIndex }) {
return { rows: items, startLine: startIndex + 1 };
}
function groupConsecutive(items, predicate) {
const groups = [];
let open = null;
for (const [index, item] of items.entries()) {
if (predicate(item)) {
open ??= { items: [], startIndex: index };
open.items.push(item);
} else if (open) {
groups.push(open);
open = null;
}
}
if (open) groups.push(open);
return groups;
}
function checkTable(table) {
return [
...findColumnCountMismatches(table),
...findColumnWidthMismatches(table),
];
}Both halves are visible at each level:
- Names reveal intent:
checkFile,findTables,checkTable,groupConsecutive,toTable,findColumnCountMismatchesall announce what, not how. - Contents reveal intent:
checkFileis a three-line type translation (filename → content → tables → errors).findTablesis two intent-named steps.checkTableis one line concatenating two named checks. State-machine bookkeeping is pushed down intogroupConsecutive, where buffer-and-flush is what the function exists to do. Each body stays at one altitude.
Errors returned from checkTable carry { line, content } only. The filename is held at the CLI layer, which already iterates over files and so already has it in hand:
js
for (const filename of files) {
for (const error of checkFile(filename)) {
console.log(`${filename}:${error.line}:${error.content}`);
}
}checkTable doesn't need to know about files because it isn't dealing with one. Removing the threaded parameter is itself a form of intention-revealing: a function should only know what it needs to know. The CLI knows about files; the table checker knows about tables. Each layer carries the context it owns.
Anti-patterns to watch for
- Mechanism in the name:
buildParentPidMap,iterateAndCheck,loopThroughResults. The name leaks the implementation. - Type or shape names:
arr,obj,str,data,result,temp,tmp,val. The name tells you the bucket, not the contents. - Generic verb-noun:
processX,handleX,manageX,doX,getX(when not a simple accessor). Replace with a verb specific to the question being answered. Util,Helper,Managerin the name: usually a container for unrelated functions. Move each function to where its name names a real role.- Negation without need:
notEmptyis harder to read thanhasItems. Name the positive case. - Inconsistent terminology:
userin one place,accountin another,membersomewhere else, all referring to the same thing. Pick one term per concept. - High-level name, low-level body: a function called
processOrder()whose body is twenty lines of inline string parsing, array bookkeeping, and conditional branches. The name promised intent; the body delivered mechanism. - Mixed altitudes inside one function: the body alternates between one-line calls to intent-named helpers and inline primitive operations (index arithmetic, buffer pushes, edge-case
ifs). Either lift the primitives into helpers or inline the calls — pick one altitude. - Missing intermediate layer: a top-level entry point that drops straight into low-level loops because no mid-level helpers exist. The reader has to descend through internals to figure out what the program is even for.
- Inline state machines with running buffers, "current" variables, and EOF tail handling sitting next to high-level calls. Push the state machine into a function whose name is "state machine over these items."
- Index arithmetic at the top level:
index + 1,arr.length - 1,slice(1, -1)mixed in with intent-named calls. Either move the arithmetic into a helper or use a higher-level operation that doesn't expose indices. - Duplicated edge cases: the same flush logic written once inside the loop and once after it. That's a sign the loop body has the wrong shape — the right abstraction has the edge case folded in.
- Comments that narrate what each block does: "Handle table at end of file", "Compute totals", "Send the email". The comment is doing the job the function name should have done.
Why one principle, not two
Beck's Implementation Patterns (2008) names two patterns: Intention-Revealing Name and Composed Method. They are usually presented as separate ideas. They are not — they are the same idea applied at two scopes.
The "one altitude per method" rule from Composed Method falls out for free once you require contents to reveal intent. A low-level mechanical line stops revealing intent and starts revealing mechanism, so it either gets lifted (into an intent-named helper) or pushed down (into a function whose name makes that level meaningful). The quality criterion is uniform: does this line reveal intent?
The frame scales beyond functions: intention-revealing parameters, tests, commit messages, log lines, error messages. Anywhere a reader will encounter your output, the same principle applies.
Lineage
The terminology comes from Kent Beck and is worth borrowing deliberately:
- Beck, Smalltalk Best Practice Patterns (1997): Intention Revealing Selector — a method name should reveal the caller's intent, not the algorithm.
- Beck, Implementation Patterns (2008): generalizes to Intention-Revealing Name and adds the companion Composed Method (a method body is calls to other methods at one consistent level of abstraction).
- Fowler, Refactoring: "Rename Method" and "Extract Method" both invoke intention-revealing names.
- Martin, Clean Code: opens the naming chapter with "Use Intention-Revealing Names," citing Beck.
This file unifies Beck's two patterns under one principle so the relationship between names and contents is explicit.
See also
- Code Style: Comments Explain Why, Not What: a "what" comment is usually a missing intent-revealing name or a missing helper.
- What Before Why or How: the same idea applied to prose — lead with the point before the mechanism.
- Refactoring: the verified-steps method to apply when restructuring code toward intent-revealing form.