Skip to main content

Human-Readable Names

Every name we write is made of whole words a person can read out loud. Variables, function parameters, props, types, constants, form fields, files, folders, routes, database columns, scripts and environment variables — everything. No shortened words, no single letters, no numbered names — not even for a loop counter or an event handler. The one exception is r and s.

Anyone reading the code should never have to guess what a name stands for.

This applies to everyone writing code, including AI agents. An agent copies the examples it sees, so every example in these standards follows this rule too.

Never / Always​

NeverAlways
btn, msg, valbutton, message, value
(e) =>, (item, i) =>(event) =>, (item, index) =>
acc in a reducerunningTotal — what it actually holds
args, params (our own)commandArguments, searchFilters
cwd (our own variable)workingDirectory
deps, envdependencies, environment
timeoutMstimeoutMilliseconds
MAX_RETRY_ATTEMPTSMAXIMUM_RETRY_ATTEMPTS
size: "sm" | "lg"size: "small" | "large"
<T><ItemType>
name="email1"name="emailAddress"
helpers.ts, misc.tsformatCurrency.ts — named after what it does
helpers/, utilities/utils/
useAuth, authStoreuseAuthentication, authenticationStore
STRIPE_SKSTRIPE_SECRET_KEY

Where it applies​

Everything that has a name:

Thing✅ Human-readable❌ Not
FoldersaccountSettings/, invoiceTable/acctSet/, misc/, stuff/, new/
FilesformatCurrency.ts, InvoiceTable.tsxfmtCur.ts, utils2.ts, helpers.ts, temp.ts
ComponentsInvoiceTable, SubmitButtonTbl, Comp1, MyComponent, Wrapper2
Component propsisDisabled, onUserSelect, invoiceTotaldis, cb, val, data
VariablesuserEmailAddress, activeUsersaddr, x, temp, data, res, obj
FunctionscalculateOrderTotal, sendWelcomeEmailcalc, doIt, process, handle
Event handlershandleSearchTextChangeonChg, handler, fn
BooleansisLoading, hasPermission, canDeleteloading, flag, ok, chk
ConstantsMAXIMUM_RETRY_ATTEMPTS, RETRY_DELAY_MILLISECONDSMAX, N, DELAY
TypesUserType, InvoiceStatusType, ButtonPropsU, Data, IProps, Obj
Generic type parameters<ItemType>, <ResponseType><T>, <K, V>
Union and enum values"processing" | "shipped" | "cancelled""p" | "s", 1 | 2 | 3
Form fieldsname="emailAddress", name="companyName"name="email1", name="fld3", name="q"
CSS Module classes.primaryButton, .invoiceRow.pb, .btn2, .wrapper3
CSS variables--color-brand-primary--c1, --blue2
Database tables and columnsdraft_ticket, created_at, invoice_totaldt, col1, amt, flag
API routes/api/calendar-events/api/ce, /api/data, /api/v2x
Environment variablesSTRIPE_SECRET_KEY, DOCS_URLSK, KEY2, URL_1
Test namesit("adds a thousands separator")it("works"), it("test 1")
Doc filesarchitecture/background-jobs.mdarch/bg.md, notes2.md
Error messages"Enter an email address""Invalid input", "Error 42"

The words come from the Ontology wherever it has a term: reusable functions are in utils/, never helpers/ or utilities/. A project's own ontology adds its product words: in Launchpad, a Draft Ticket is draftTicket, never pendingTicket. The casing comes from Naming Conventions.


Form fields​

Form fields are where names slip most, and where they matter most: a field's name travels through the whole system.

One field, one name, everywhere it goes. The same words name the input, the form's data, the API request, the type and the database column:

WhereName
Input name and idemailAddress
Form data and validation schemaemailAddress
API request bodyemailAddress
Type propertyemailAddress: string
Database columnemail_address
// ✅ Good
<label htmlFor="emailAddress">Email address</label>
<input id="emailAddress" name="emailAddress" type="email" />

<label htmlFor="billingFrequency">Billing frequency</label>
<select id="billingFrequency" name="billingFrequency">
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
// ❌ Bad
<input id="f3" name="email1" placeholder="Email" />

<select name="freq">
<option value="1">Monthly</option>
<option value="2">Yearly</option>
</select>
  • name and id are full words, and match each other.
  • Every field has a visible <label> in plain English. A placeholder is never the label.
  • Option values are words, not numbers: value="monthly", not value="1".
  • Buttons with only an icon get an aria-label that says what they do: aria-label="Delete invoice".
  • Validation messages name the field and say what to do: "Enter an email address", not "Required".

id is a word​

id is its own word. Never write it out as identifier.

WhereWrite
A property or variableid, userId, draftTicketId
A list of themuserIds
A constantDEFAULT_PROJECT_ID
A database columnuser_id
  • Case it as a word: userId, never userID.
  • identifier means something else. Use it only when an outside system has a separate field with that name. Linear has both: id is its hidden unique code, and identifier is the readable ACT-73. Writing identifier for an id would mix the two up.

Dictionary​

Some short names are not abbreviations. They are the word people actually say, and the long form would only make the name harder to read. These are dictionary words, and stay short:

WordMeansExample names
idA unique iduserId, user_id, DEFAULT_PROJECT_ID
docsA project's documentationdocs/, docsSite/, DOCS_URL, pnpm docs:dev
libThe folder for outside connections and app-wide setup. Next.js and shadcn/ui use the same namelib/, lib/integrations/
propsThe values a component takesprops, ButtonProps
refA reference or handle to something: a React ref, or any other handle, such as a terminal tab's refinputRef, useRef, surfaceRef
repoA repository: in GitHub's words, it "contains all of your project's files and each file's revision history"repoName, repoUrl
utilsReusable functions that are not componentsutils/, utils/formatCurrency/
appThe applicationapp/, appName
urlA web addressimageUrl, DOCS_URL
apiAn application programming interfaceapi/, apiResponse
uatUser acceptance testinguatEnvironment
pdf, html, css, jsonFile formatsinvoicePdf, htmlContent, globals.css
readmeA project's README filereadmeContent

Case them as words in camelCase and PascalCase: imageUrl, apiResponse, docsSite — never imageURL or APIResponse.

This list is the only back door. A short name not on it gets written out in full. If people say the long form out loud — "environment", "dependencies", "message" — write the long form.

Adding a word to the dictionary​

When a short name feels like a dictionary word, don't just use it — add it here first:

  1. Ask: is this the word people actually say, so the long form would only be harder to read? docs, repo: yes. env, btn, msg: no.
  2. Add a row to the table above, before or alongside the code that uses it.
  3. If ESLint flags the word, add it to replacements in the unicorn/name-replacements rule of the ESLint config, so the linter agrees: replacements: { docs: false, lib: false, props: false, ref: false, repo: false, utils: false }.

The list is the record. If a word is not on it, it is not a dictionary word yet.

r and s: the only single letters​

Two names are allowed to be a single letter: r, the object of every route, and s, the object of every piece of static text. Both live in lib/constants/. See Constants.

They are used so often that the short form is easier to read: href={r.contact} and {s.contact} read cleanly in a component, where routes.contact and strings.contact crowd it.

  • Only these two. No other single letter is allowed anywhere: not e, i, x or T, and not another constants object.
  • Only as those two objects. r is never a request or a result, and s is never a string or a second.
  • Their keys are full words: r.linkedIn, s.pageNotFound.

Third-party names​

This rule is for the names we write. It does not cover names that come from someone else's code — a package, a framework, an outside service. Those stay exactly as they are: everyone knows them by that name, their docs use that name, and renaming them breaks the code. Use them as they are where they touch the outside, then give the value a full-word name as soon as it comes into our code.

FromExamples
An outside service's dataLinear's id, identifier and pageInfo
Node and library optionscwd in execFile, process.env, stdout, argv
Reactref, children, useRef, useEffect
shadcn/uithe cn class-name function, and the component files it generates
Next.jsthe app/ folder, params, searchParams, LayoutProps, generateMetadata
HTML and CSShref, src, alt, tabIndex, Tailwind classes like text-sm
Toolsnode_modules, .env.local, Docusaurus's src/pages, Storybook's args, the dev and prepare scripts
A project's own ontologyIts decided product terms, such as Launchpad's dev_agent
// ✅ Good — the option keeps its third-party name; our value gets a full-word name
const workingDirectory = process.cwd();
const commandArguments = ["lint", "--fix"];

execFile("pnpm", commandArguments, { cwd: workingDirectory });
// ❌ Bad — renaming the third-party option breaks the call: execFile ignores "workingDirectory"
execFile("pnpm", commandArguments, { workingDirectory });

// ❌ Bad — the third-party name leaks into our own code
const cwd = process.cwd();

When a name clashes​

Keep a third-party import's own name. When it clashes with something the file declares itself, rename our own thing first. A clash usually means our name is too vague: give it a more specific full-word name that says what it actually is.

// ✅ Good — our function says what it makes, so Supabase's createClient keeps its name
import { createClient } from "@supabase/supabase-js";

export const createProjectDatabaseClient = () =>
createClient(DATABASE_URL, DATABASE_KEY);
// ❌ Bad — the import is renamed to make room for our vague name
import { createClient as createSupabaseClient } from "@supabase/supabase-js";

export const createClient = () =>
createSupabaseClient(DATABASE_URL, DATABASE_KEY);

Rename the import only when our side can't change:

The clashWhy our side can't changeRename the import to
Two third-party names, such as Link from next/link and the Link icon from lucide-reactNeither name is oursThe owner plus the original name: Link as LucideLinkIcon
Our name is fixed by a framework, such as a Next.js route's GET or generateMetadataThe framework finds it only by that exact nameThe owner plus the original name
// ✅ Good — two third-party names clash, so one import is renamed: owner plus original name
import Link from "next/link";
import { Link as LucideLinkIcon } from "lucide-react";
// ❌ Bad — renamed for taste, or shortened
import { createClient as makeDb } from "@supabase/supabase-js";
import { Link as Icon } from "lucide-react";

Never rename an import for taste, and never shorten it.


Examples in code​

// ✅ Good
const userEmailAddress = user.email;
const isSubmitButtonDisabled = !isFormValid;

const calculateOrderTotal = (orderItems: OrderItemType[]) => {
return orderItems.reduce(
(runningTotal, orderItem) => runningTotal + orderItem.price,
0,
);
};

users.map((user) => user.name);
// ❌ Bad
const addr = user.email;
const btnDis = !valid;

const calc = (arr: OrderItemType[]) => {
return arr.reduce((acc, i) => acc + i.price, 0);
};

users.map((u) => u.name);

How it is checked​

NamesChecked by
Variables, functions, parameters and file namesESLint unicorn/name-replacements and unicorn/filename-case
Type namesESLint @typescript-eslint/naming-convention
CSS variables and CSS Module classesStylelint
Everything else — folders, props, form fields, union values, database columns, routes, environment variables, test namesCode review and AI agents. The linter cannot read these, so every agent and reviewer checks them

ESLint allows the dictionary words it would otherwise flag (docs, lib, props, ref, repo, utils), and exactly two third-party names as they are: params and searchParams. It still flags any of our own names built from an third-party name, such as inputRef.

ESLint does not flag r or s. Reviewers check that they are only ever the two constants objects.