Skip to content
Chapter 2Lesson 3

Name for intent, not implementation

The TypeScript naming discipline: label variables, functions, parameters, and types for what they mean, not how they are built.

Open any production repo and you’ll find three kinds of bugs hiding in the names. A variable called data whose shape nobody can know without opening three other files. A function called processOrder whose body the next reader has to read end to end just to learn what “process” means here. A boolean called notDisabled that someone negates six months later as !notDisabled, a double negative the reader has to cancel out and one day cancels wrong.

None of these are style preferences; they are documentation failures. The name is the only part of the code the next reader sees before the implementation, so a bad one charges a small cost to every reader of every future PR. The fix is one principle plus three classes of failure you’ll learn to spot in a diff.

Like the const-by-default rule, this is a discipline, not a syntax feature. TypeScript can’t catch a misleading name and Biome lints only the rougher edges, so most of the work lives in the reviewing habit you’ll build here.

The principle treats its two directions differently: it tolerates one and forbids the other. A vague but fitting name like user, total, or invoices is fine; the reader learns what the value is, even without every detail. A name that leaks the implementation, like userArray, totalReducer, or invoicesQueryResult, is the problem: it tells the reader how today’s code computes the value, so a future refactor makes the name lie. The principle isn’t asking you to be specific. It asks you to describe what the value is, not how it’s produced.

Writing the same case both ways makes the asymmetry concrete. Both tabs below define a variable holding a customer’s pending invoices; the function call is identical, only the names change.

const invoices = await getPendingInvoices(customerId);

invoices says nothing about the source, the filter, or the type, and that’s fine: the name fits what the value is, a collection of invoices. Rework getPendingInvoices tomorrow to read from a cache or paginate, and the name still fits. Vague is acceptable when the value’s identity is all the reader needs.

“Vague is acceptable” doesn’t mean vague is the goal. The right name is as specific as the value warrants: pendingInvoices beats invoices when the pending filter is the whole point of the variable. The principle only asks that the extra specificity describe the value, not the implementation behind it.

Every name in a 2026 codebase lives on one of four surfaces: variables, functions, parameters, types. The principle holds across all four, but the local rules and the payoff differ.

Variables are nouns, and concrete beats abstract: pendingInvoices over data, activeUser over obj. One twist is worth stating explicitly: length is proportional to scope. A one-line callback parameter can be x, because its scope is the single expression it lives in. A module-level constant cannot, because a reader far from the assignment needs the name to tell them what the value is.

const totalCents = invoices.reduce((sum, invoice) => sum + invoice.cents, 0);
const pendingInvoicesForCurrentMonth = await db
.select()
.from(invoicesTable)
.where(/* customerId + status filter */);

sum and invoice are fine inside the one-line .reduce: each lives for one expression, and its position in the callback (accumulator first, item second) tells the reader what it is. pendingInvoicesForCurrentMonth needs its full name because it lives at module scope, where a reader who jumps to its first use has none of the context the writer had at assignment time.

Functions: verbs that signal the kind of operation

Section titled “Functions: verbs that signal the kind of operation”

Functions are verbs or verb phrases. The verb does double duty: it tells the reader the function does something, and it signals what kind of operation that is:

  • load, fetch, get for reads
  • create, update, archive for writes
  • parse, validate for transformation
  • format, render for output projection

A reviewer skims the verbs and knows each function’s category before reading any body, which is what lets you read a file at scrolling speed.

const invoice = await getInvoice(id);
const validated = parseCreateInvoiceInput(formData);
const formatted = formatCurrency(invoice.cents, 'USD');
await archiveInvoice(invoice.id);

Here get is a read returning the row or null, parse transforms raw input into a validated value, format is an output projection, and archive is a write. The category is legible without opening a single body.

The course doesn’t mandate a single verb glossary: fetchInvoice, loadInvoice, and getInvoice all communicate intent, and each team picks for its own codebase. This course uses getInvoice for single-record reads, listInvoices for collections, requireInvoice for reads that throw on miss, and verb+noun for Server Actions like createInvoice. The rule isn’t which verb you pick; it’s that you pick one verb per concept and use it consistently across the codebase.

Parameters follow the variable rules with one tighter constraint: parameter names appear in the function’s public surface . TypeScript shows them on hover, in error messages, and in IDE tooltips, so a vague parameter name pollutes every call site: every developer who hovers the function pays a small readability cost.

The contrast is clearest when you picture the IDE tooltip the next developer sees.

const createUser = ({ name, email, role }: CreateUserInput) => {
// ...
};
createUser({ name: 'alex', email: 'a@x.com', role: 'admin' });

On hover the IDE shows the destructured fields name, email, role. The call site reads as English: every argument names itself, and a reviewer skimming it needs no context from the function body.

The destructured shape ({ name, email, role }: CreateUserInput) is the canonical form for any function that takes more than one input, the options-object default from the previous lesson. The naming rule applies whether you destructure at the signature or pull the names off the options object inside the body: every name in that destructure shows up on hover.

Types and type members: PascalCase nouns, no noise suffixes

Section titled “Types and type members: PascalCase nouns, no noise suffixes”

Types are PascalCase nouns. Fields are camelCase. The course never writes Type or Interface as a suffix and never writes I as a prefix.

type Invoice = {
id: string;
customerId: string;
status: 'paid' | 'pending' | 'overdue';
amountCents: number;
};

The alias name is a noun: Invoice, not InvoiceType. The type keyword already marks the right-hand side as a type, so a Type suffix only adds noise the reader has to skip. The same logic rules out the I-prefix convention from older Java and C# codebases (IUser, IInvoice) and the Hungarian notation markers from older C codebases (EStatus, bIsAdmin). TypeScript already knows what’s a type and what’s a value, so neither convention disambiguates anything; if you meet them in third-party code, don’t carry them into your own.

The boolean prefix is a visual contract: when the reader sees is, they know the value is a boolean before they reach the annotation. It buys reading speed, not type safety.

Five prefixes cover almost every case:

  • is*: current state. isAdmin, isLoading, isPublished.
  • has*: possession or membership. hasUnpaidInvoices, hasAccess, hasErrors.
  • can*: permission or capability. canEdit, canDelete, canRetry.
  • should*: conditional intent. shouldRetry, shouldRevalidate, shouldOpenOnMount.
  • will*: future state or pending behavior. Rare; reach for it only when the timing matters.

More than one prefix is often acceptable, so don’t agonize over the choice. The discipline is to use some prefix from this set every time, so the truth condition is visible at a glance.

const isAdmin = user.role === 'admin';
const hasUnpaidInvoices = invoices.some((invoice) => invoice.status === 'pending');
const canEditInvoice = isAdmin || invoice.ownerId === user.id;

Each name reads as a question the value answers: “is this user an admin?”, “does this collection have unpaid invoices?”, “can this user edit this invoice?”.

Almost every naming failure is one of three kinds, and once you spot the kind, the fix is obvious:

  1. Implementation-leaking: the container or origin is in the name.
  2. Vague abstractions: the name fits any value and communicates none.
  3. Negated booleans: the name carries a negation that compounds at use sites.

Implementation-leaking: the container is in the name

Section titled “Implementation-leaking: the container is in the name”

The smell: the container or representation appears in the name. userArray, customerMap, loadingFlag, invoicesQueryResult. The name encodes how the value is stored or where it came from, so it lies the moment someone changes either.

The fix: name what’s in the container, not the container. Plurality already signals a collection; the boolean prefix already signals a flag; the destination already signals the purpose.

const customerArray = await listCustomers();
const loadingFlag = false;
const invoicesQueryResult = await db.select().from(invoicesTable);

Every name leaks today’s implementation. Switch customerArray to a Map and the name lies. Replace loadingFlag with any boolean-ish value (a state-machine value, a Promise’s pending status) and it lies. Move invoicesQueryResult to a cached read and it lies.

Vague abstractions: names that fit anything fit nothing

Section titled “Vague abstractions: names that fit anything fit nothing”

The smell: names that could attach to any value in the codebase, like data, info, result, manager, helper, util, handler. Because they fit everything, they communicate nothing.

The fix: replace each with the concrete thing the value is. data becomes weeklyMetrics, result becomes validatedInput. The substitution is mechanical: read where the value comes from and use that as the noun.

const data = await fetchWeeklyMetrics();
const weeklyMetrics = await fetchWeeklyMetrics();
const result = validateInput(form);
const validatedInput = validateInput(form);

Now the next reader sees what the value represents without opening the function that produced it.

When a file’s central object is genuinely named manager or helper, that’s a refactor signal, not just a naming smell. A UserManager class usually turns out to be three or four unrelated operations bundled together: findUser, sendEmail, validatePermission, logAudit, sharing one class because the author had no name for any of them. The course’s answer is structural: put pure helpers in /lib with verb-led names (buildObjectKey, parseCursor, roleAtLeast). If you can’t name the abstraction, you don’t have one; you have a bag of operations that each deserve their own function.

Negated booleans: the double negative at the use site

Section titled “Negated booleans: the double negative at the use site”

The smell: a boolean with a negation baked into the name. notDisabled, isNotLoading, noErrors. The negation becomes a permanent feature of the binding, so it compounds with the ! operator at every use site. !notDisabled means “disabled,” but never says so, and six months later a reader pattern-matches it to “not disabled” and gets it exactly backwards.

The fix: name the positive condition. isEnabled instead of notDisabled, isLoading instead of isNotLoading, hasErrors instead of noErrors. The negation now lives only at the use site, where it reads in one pass.

if (!notDisabled) submitButton.disabled = true;
if (!isEnabled) submitButton.disabled = true;

!notDisabled is a double negative: the reader has to cancel two negations to know which condition fires the body. Eventually a teammate cancels them wrong, and the button never disables when it should.

if (!notDisabled) submitButton.disabled = true;
if (!isEnabled) submitButton.disabled = true;

!isEnabled reads in one pass: “if not enabled, disable the button.” One negation paired with a positive name, so the meaning is unambiguous. Same runtime behavior, less reading effort.

1 / 1

The Code conventions doc bans negated booleans across the codebase. Biome ships no built-in rule for this, so review-time attention is the safety net.

Don’t abbreviate unless the short form is more common than the spelled-out form in the domain. Acceptable: url, id, db, api, http, jwt, ms (milliseconds), auth, env. These read instantly to any web developer; spelling them out (uniformResourceLocator, identifier, database) would be the surprising choice.

Never invent new ones: not usr for user, not prfl for profile, not qty for quantity, not acct for account. A reader pays more to decode an unfamiliar abbreviation than you pay to type the full word, and that cost lands on every future reader.

When two names both pass the principle (fetchUser and loadUser, customers and customerList, validate and check), either is fine in isolation, so the choice is a call for the team, not the course.

What the course does require: pick one per concept across the codebase and stick to it. The real problem is drift, not the choice. A repo that says fetchUser in one file and loadUser in the next forces every reader to track two names for one operation, and that cost compounds across hundreds of files. Pick one verb per concept, record it in the team’s conventions doc, and let a linter catch drift if the volume warrants it.

The point isn’t to memorize the rules; it’s to leave with a reflex that fires when a bad name shows up in a diff. Run three filters on every name you review: does it leak the implementation? Does it fit any value? Does it carry a negation?

Review the PR below. Three of the five named values trip one of those filters; two are fine. Click the offending lines and leave inline comments: name the class and propose the fix.

Review this PR. Flag every name that violates the principle — name for intent, not implementation. Three of the five named values have problems. Two are fine. Click any line to leave a review comment, then press Submit review.

src/invoices/summary.ts
import { db } from '@/db';
import { invoicesTable } from '@/db/schema';
export const getInvoiceSummary = async (customerId: string) => {
const invoicesQueryResult = await db
.select()
.from(invoicesTable)
.where(/* customerId + pending status filter */);
const data = invoicesQueryResult.filter((invoice) => invoice.status === 'pending');
const notPaid = data.length > 0;
const totalCents = data.reduce((sum, invoice) => sum + invoice.cents, 0);
return { pending: data, hasPending: notPaid, totalCents };
};