Lesson 01 / 16
Meaningful Names
Treating naming as a measurable design decision: counting opaque arguments at the call site in a shortened and an intent-revealing version of the same shipment fee calculation, deriving how many candidate words an abbreviation could resolve to, and the relationship between name length and the scope a name lives in.
Contents
Completing a language curriculum yields one capability: writing code that produces the requested behavior. The JavaScript and TypeScript courses delivered that; if the tests pass, the program is correct. This curriculum opens with a different question. When a choice must be made between two programs that produce the same behavior, what does the choice rest on?
The question is unavoidable, because code is not written once and abandoned. A shipment fee library gets a new zone coefficient six months later, and a volumetric weight divisor changes a year after that. Whoever makes those changes reads the code first. Reading cost is the actual measure of a design, and a claim like “more readable” does not settle an argument until tied to a number. This lesson ties naming to two numbers: the number of arguments at the call site whose meaning cannot be answered, and how many different domain words an abbreviation could resolve to.
One library runs through the course: shipment fee calculation and routing. Its first function computes a fee from a shipment’s weight, volume, and zone.
Measure: The Question Answerable at the Call Site
The information a name carries is measured where it is used. Someone looking at a call line asks: which parameter does this argument fill, what does its value mean, what is the result a fee for? If answering requires opening another file, the name is not carrying information.
To make this mechanical, call an argument labeled if the call text itself states which parameter it fills — a named field, or a variable name of at least three letters. Otherwise it is opaque: a bare number, a boolean, or a single-letter name. The number of opaque arguments is a lower bound on the number of questions that cannot be answered at the call site.
The Abbreviated Version
The first version works and passes its tests. It computes the volumetric weight, finds the billable weight, selects the tier, applies the zone coefficient, subtracts the discount, and does not drop below the minimum fee.
mkdir -p version-a version-b
// version-a/data.mjs — sample shipment and tariff export const s = { w: 2.4, wd: 30, l: 24, h: 18 }; export const t = { tr: [{ u: 1, r: 38 }, { u: 5, r: 26 }, { u: 10, r: 21 }, { u: Infinity, r: 17 }], zc: { 1: 1, 2: 1.35, 3: 1.8 }, mf: 45 };
// version-a/fee.mjs — shipment fee, abbreviated names export function calc(s, t, z, x) { const v = (s.wd * s.l * s.h) / 3000; const a = Math.max(s.w, v); const k = t.tr.find((i) => a <= i.u); const f = k.r * a * t.zc[z]; return Math.max(f * (1 - x), t.mf); }
// version-a/call.mjs — call site import { calc } from "./fee.mjs"; import { s, t } from "./data.mjs"; console.log(calc(s, t, 2, 0.12).toFixed(2));
None of the four arguments at the call site explains itself. Is 2 a zone code, a tier
index, a quantity? Is 0.12 a discount rate, a tax rate? The answer sits in fee.mjs;
whoever is reading has to open a second file.
The Named Version
The second version performs the same arithmetic. What changes is the names, the parameter shape, and where the numbers come from.
// version-b/data.mjs — same data, with field names export const ZONE = { SAME_CITY: 1, NEIGHBORING_ZONE: 2, DISTANT_ZONE: 3 }; export const CONTRACTED_CUSTOMER_DISCOUNT = 0.12; export const sampleShipment = { weightKg: 2.4, widthCm: 30, lengthCm: 24, heightCm: 18 }; export const domesticTariff = { weightTiers: [{ upperLimitKg: 1, ratePerKg: 38 }, { upperLimitKg: 5, ratePerKg: 26 }, { upperLimitKg: 10, ratePerKg: 21 }, { upperLimitKg: Infinity, ratePerKg: 17 }], zoneCoefficients: { 1: 1, 2: 1.35, 3: 1.8 }, minimumFee: 45, };
// version-b/fee.mjs — same calculation, with intent-revealing names const VOLUMETRIC_DIVISOR = 3000; export function calculateShipmentFee({ shipment, tariff, zoneCode, discountRate }) { const volumetricWeightKg = (shipment.widthCm * shipment.lengthCm * shipment.heightCm) / VOLUMETRIC_DIVISOR; const billableWeightKg = Math.max(shipment.weightKg, volumetricWeightKg); const tier = tariff.weightTiers.find((t) => billableWeightKg <= t.upperLimitKg); const zonedFee = tier.ratePerKg * billableWeightKg * tariff.zoneCoefficients[zoneCode]; return Math.max(zonedFee * (1 - discountRate), tariff.minimumFee); }
// version-b/call.mjs — call site import { calculateShipmentFee } from "./fee.mjs"; import { sampleShipment, domesticTariff, ZONE, CONTRACTED_CUSTOMER_DISCOUNT } from "./data.mjs"; console.log(calculateShipmentFee({ shipment: sampleShipment, tariff: domesticTariff, zoneCode: ZONE.NEIGHBORING_ZONE, discountRate: CONTRACTED_CUSTOMER_DISCOUNT }).toFixed(2));
Both versions are confirmed to produce the same result.
node version-a/call.mjs node version-b/call.mjs
133.44 133.44
The behavior is identical. The difference is only in reading cost, and that cost is countable.
Measurement
The tool below counts two things. First, the opaque arguments at a given call site. Second, taking every name in the implementation that is two letters or shorter, how many words in the domain vocabulary each one could resolve to — that is, how ambiguous the abbreviation is.
// name-measure.mjs — counts opaque call-site arguments and how many domain words an abbreviation could mean import { readFileSync } from "node:fs"; // The library's domain vocabulary: words a name is expected to carry. const GLOSSARY = ["coefficient", "discount", "divisor", "fee", "kilogram", "limit", "minimum", "rate", "shipment", "tariff", "tier", "volume", "volumetric", "weight", "zone"]; // An argument is labeled if the call text itself states which parameter it fills // (a named field, or a variable name of at least three letters). const labeled = (arg) => /^[A-Za-z_$][\w$]{2,}\s*:/.test(arg) || /^[A-Za-z_$][\w$]{2,}$/.test(arg); function countOpaque(file, fnName) { const body = readFileSync(file, "utf8"); const call = body.match(new RegExp(`\\b${fnName}\\(([^()]*)\\)`)); const args = call[1].replace(/[{}]/g, "").split(",").map((s) => s.trim()).filter(Boolean); return { total: args.length, opaque: args.filter((a) => !labeled(a)).length }; } function abbreviations(file) { const body = readFileSync(file, "utf8").replace(/^\/\/.*$/gm, ""); const names = [...new Set(body.match(/[A-Za-z_$][\w$]*/g))].filter((a) => a.length <= 2); return names.map((name) => ({ name, candidates: GLOSSARY.filter((g) => g.startsWith(name[0].toLowerCase())), })); } for (const [file, fn] of [["version-a/call.mjs", "calc"], ["version-b/call.mjs", "calculateShipmentFee"]]) { const r = countOpaque(file, fn); console.log(`${file.padEnd(20)} ${fn.padEnd(21)} arguments=${r.total} opaque=${r.opaque}`); } console.log("\nabbreviation candidate count candidates"); const short = abbreviations("version-a/fee.mjs"); for (const s of short) console.log(`${s.name.padEnd(9)} ${String(s.candidates.length).padEnd(12)} ${s.candidates.join(", ") || "-"}`); const unique = short.filter((s) => s.candidates.length === 1).length; console.log(`\nversion-a abbreviation count = ${short.length}, resolve to one candidate = ${unique}`); console.log(`version-b abbreviation count = ${abbreviations("version-b/fee.mjs").length}`);
node name-measure.mjs
version-a/call.mjs calc arguments=4 opaque=4 version-b/call.mjs calculateShipmentFee arguments=4 opaque=0 abbreviation candidate count candidates s 1 shipment t 2 tariff, tier z 1 zone x 0 - v 2 volume, volumetric wd 1 weight l 1 limit h 0 - a 0 - w 1 weight k 1 kilogram tr 2 tariff, tier i 0 - u 0 - f 1 fee r 1 rate zc 1 zone mf 1 minimum version-a abbreviation count = 18, resolve to one candidate = 10 version-b abbreviation count = 1
Four opaque arguments dropped to zero. The abbreviation table gives the second result: of
eighteen short names in the abbreviated implementation, ten resolve to exactly one
glossary word, five resolve to none, and three resolve to more than one. Ambiguity is not
the only failure mode. w genuinely stands for weight and resolves to weight; wd
stands for width but resolves to that same single candidate, weight — a confident,
wrong answer. k stands for the selected tier and resolves only to kilogram, another
confident, wrong answer. A single matching candidate is not proof the name is right.
The named version keeps exactly one short name: the t parameter passed to the find
callback. That leftover is not a coincidence; it is the subject of the next section.
Name Length Depends on Scope
A short name is not a defect by itself. The measure is the scope the name lives in.
The difference between the t inside a one-line callback and a module-level export named
t is the distance a reader travels to find the definition: on the same line in the
first case, in another file in the second.
A practical rule follows: a name’s length should grow with the amount of code it stays
visible across. A loop counter i is defensible inside a three-line loop, not as an
exported constant. The reasoning runs in reverse too: an unnecessarily long name in a
narrow scope (currentShipmentVolumetricWeightValue) lengthens the line without adding
information.
What a Name Should Convey
The measurement says how much is missing; it does not say which information needs adding. Four criteria do that work.
Units and type live inside the name. The difference between weightKg and weight
is that a mistake mixing grams with kilograms becomes visible before the code compiles.
The suffixes on widthCm, upperLimitKg, and discountRate do this work;
discountRate and discountAmount cannot substitute for each other.
A name must not lie. A variable named zoneList that actually holds a mapping is
worse than no name at all: whoever reads it proceeds on a false assumption. The cost of a
wrong name is every conclusion drawn before the mistake is found.
One word per concept. If getTariff, fetchTariff, and retrieveTariff all appear
in the same library, the reader assumes three different operations. The vocabulary stays
fixed throughout the course: a shipment’s billable weight appears everywhere as
billableWeightKg.
Searchability. The number 3000 appears in hundreds of places across a codebase;
VOLUMETRIC_DIVISOR appears once. When the number needs to change, the name is what gets
searched for. Leaving a bare number unnamed costs not only reading, but changing the
code.
Summary
- Naming is not a stylistic preference but a measurable decision: the measure is the opaque argument count, a lower bound on the questions a call site cannot answer.
- In the abbreviated version, all four arguments were opaque; in the named version, none were. Both versions produce the same result (133.44); the difference is only in reading cost.
- The abbreviation-resolution table gave the second measure: of eighteen short names, ten resolve to exactly one domain word, five to none, and three to more than one — and a single matching candidate is not proof the name is right.
- Name length grows with the scope a name lives in; a short name inside a one-line callback and an exported short name are not the same thing.
- A good name carries a unit and a type, does not lie, uses one word per concept, and is searchable; naming a bare number reduces the cost of changing code, not just reading it.
Next Step
One thing stands out in the named version: the VOLUMETRIC_DIVISOR constant needed no
comment line, because its name already says what it is. In the abbreviated version, the
same information could only be given through a comment. Does this observation
generalize? Is the presence of a comment a sign that the code had to say something it
could not say itself? The next lesson answers this by measurement: how much of a comment
can be translated into a name, which comments cannot be, and at what point a comment
drifting apart from the code turns into a silent error.
To keep your progress and take notes, Log in
My notes
Log in to take notes.