Loading content...
Before a developer reads documentation, before they look at type signatures, before they examine the implementation—they see the name. The name of a function, a class, a variable, or a parameter is the first thing that sets expectations.
await userService.deleteUser(userId);
What happens when this runs? A competent developer would expect the user to be removed from the system. But what if deleteUser only marks the user as 'deleted' in a flag? What if it queues the deletion for later? What if it deletes the user from one database but not another?
In each case, the name deleteUser made a promise that the behavior didn't keep. The developer is astonished when they discover the truth—usually after a bug, a production incident, or hours of debugging.
Names are not documentation. Names are contracts.
By the end of this page, you'll understand how naming choices create or violate expectations, common naming patterns that lead to astonishment, specific techniques for choosing names that accurately communicate intent, and how to develop a 'naming sense' that makes your code self-documenting.
The claim 'naming is hard' has become a cliché, but few developers understand why it's hard or why it matters. Naming isn't hard because words are hard. Naming is hard because naming is specification.
When you name something, you're making claims about:
The Reading Asymmetry:
Code is read far more often than it's written. A function you write in 10 minutes might be read by hundreds of developers over years. Each reader forms expectations from the name in milliseconds. If those expectations are wrong, every reader pays a cost: confusion, debugging, bugs. The cost of a bad name compounds across every reader, forever.
| Activity | Time | Frequency | Total Impact |
|---|---|---|---|
| Writing the name | 2 seconds | Once | 2 seconds |
| Reading the name | 0.5 seconds | 1000+ times | 500+ seconds |
| Misunderstanding the name | 5-30 minutes | 1-100 times | Hours to days |
| Debugging due to misunderstanding | 30 min - 2 hours | 1-10 times | Hours to weeks |
| Production incident due to misunderstanding | Hours + incident cost | 0-few times | Potentially catastrophic |
Developers don't read code—they skim it. They look at names and form mental models without reading implementations. A good name lets them skip to the next line with correct understanding. A bad name either requires reading the implementation (slow) or creates incorrect understanding (dangerous).
Some naming patterns consistently create astonishment. Recognizing these anti-patterns is the first step to avoiding them:
queue.remove(item) suggests the item is immediately removed, but it might only mark it for later removal. If removal is deferred, say so: queue.scheduleRemoval(item) or queue.markForRemoval(item).cache.getValue(key) that creates the key if missing. The 'get' prefix universally implies read-only access. If it modifies state, it's not a 'get': use cache.getOrCreate(key) or cache.computeIfAbsent(key, factory).validateAndSave(entity) that validates and queues but doesn't actually save synchronously. Every word in the name should be accurate. Rename to validateAndQueue(entity).user.delete() where 'delete' means soft-delete in this codebase but hard-delete in the language's standard library. Either align with external conventions or be explicit: user.softDelete() vs user.permanentlyDelete().proc, mgr, svc, impl save a few characters but hide meaning. processManager vs procMgr—the former is instantly clear; the latter requires context. Save abbreviations for truly universal terms (id, url, html).handle(), process(), doWork(), run(), execute() tell you nothing about what is being handled. Prefer specific names: handlePaymentFailure(), processRefundRequest(), executeScheduledTasks().isValid that returns true for valid items... or is it isInvalid? Negative booleans (notEmpty, isDisabled, hasNoErrors) force readers to perform mental negation. Prefer positive phrasing.user.remove() — soft-deletescache.get(k) — creates if missingorder.submit() — saves draft onlyvalidate() — also logs + notifiesconfig.set(k, v) — requires restartfile.close() — flushes asyncuser.softDelete() or user.archive()cache.getOrCreate(k, factory)order.saveDraft()validateAndNotify()config.setRequiringRestart(k, v)file.closeAsync() → returns PromiseProgramming languages and communities develop conventions that carry implicit promises. Violating these conventions violates POLA even if your behavior is documented.
Java/C# Conventions:
| Prefix/Pattern | Implied Promise |
|---|---|
get*() | Read-only accessor; no side effects; returns value |
set*() | Mutator; modifies object state; void or fluent return |
is*(), has*() | Boolean accessor; cheap; no side effects |
create*(), new*() | Factory; returns new instance |
find*() | Query; may return null/empty |
to*() | Conversion; returns new instance of different type |
Python Conventions:
| Pattern | Implied Promise |
|---|---|
_single_underscore | Internal use; not part of public API |
__double_underscore | Name mangling; avoid external access |
| No return statement | Function returns None |
@property | Attribute-like access; computed but cheap |
1234567891011121314151617181920212223242526
// ❌ Convention violations that astonish class UserRepository { // 'get' implies no side effects, but this modifies lastAccessTime getUser(id: string): User { const user = this.db.find(id); user.lastAccessTime = Date.now(); // Side effect! return user; } // 'is' implies cheap boolean, but this makes network call async isUserActive(id: string): Promise<boolean> { const response = await fetch(`/api/users/${id}/status`); return response.json().active; // Network call! } // 'create' implies new instance, but this returns cached instance createLogger(name: string): Logger { if (this.loggers.has(name)) { return this.loggers.get(name); // Not creating! } const logger = new Logger(name); this.loggers.set(name, logger); return logger; }}12345678910111213141516171819202122232425262728293031
// ✅ Respecting conventions class UserRepository { // Read-only: matches 'get' convention getUser(id: string): User { return this.db.find(id); } // Side effect: name reflects it getUserAndRecordAccess(id: string): User { const user = this.db.find(id); user.lastAccessTime = Date.now(); return user; } // Async and potentially slow: name reflects it async checkIsUserActive(id: string): Promise<boolean> { const response = await fetch(`/api/users/${id}/status`); return response.json().active; } // May return cached: name reflects it getOrCreateLogger(name: string): Logger { if (this.loggers.has(name)) { return this.loggers.get(name); } const logger = new Logger(name); this.loggers.set(name, logger); return logger; }}Be careful with conventions when working across languages. A 'property' in Python has different expectations than a 'property' in JavaScript or C#. A 'module' means something different in Python vs JavaScript vs Ruby. When bridging languages, consider whether your audience's mental model comes from the source language or target language.
Expert developers maintain a mental vocabulary of terms with precise meanings. This vocabulary enables precise communication through names:
Precision in CRUD Operations:
| Term | Precise Meaning | Contrast With |
|---|---|---|
| create | Insert new; fail if exists | save (upsert) |
| insert | Add to collection; duplicates allowed | add (no duplicates) |
| add | Add if not present; idempotent | insert (allows dupes) |
| update | Modify existing; fail if not exists | save (upsert) |
| save | Insert or update; upsert semantics | create/update |
| delete | Remove permanently; hard delete | remove/archive |
| remove | Remove from collection; may be soft | delete (permanent) |
| archive | Soft delete; can be restored | delete (permanent) |
| purge | Delete and clean up all traces | delete (just record) |
| restore | Bring back archived/deleted item | create (from scratch) |
Precision in Query Operations:
| Term | Precise Meaning | Return on Not Found |
|---|---|---|
| find | Query; may not exist | null/undefined |
| get | Access; assumed to exist | throw if not found |
| lookup | Search by key | null/undefined |
| fetch | Retrieve from remote source | throw on failure |
| load | Retrieve and populate object | throw if not found |
| search | Query with criteria; multiple results | empty collection |
| query | Execute query; multiple results | empty collection |
| exists | Check existence | boolean |
| contains | Check membership | boolean |
Precision in State Changes:
| Term | Precise Meaning |
|---|---|
| initialize | Set initial state; call once |
| configure | Set options; call before start |
| start | Begin operation; transition to running |
| stop | End operation; can restart |
| pause | Temporarily halt; resume possible |
| resume | Continue after pause |
| reset | Return to initial state |
| clear | Remove content; keep structure |
| destroy | Clean up; cannot reuse |
| dispose | Release resources; cannot reuse |
Create and maintain a glossary of terms for your team or project. Document exactly what 'delete' vs 'archive' vs 'purge' means in your system. When everyone shares the same vocabulary, names become reliable communication.
One of the most common sources of astonishment is timing. When does something happen? Immediately? Later? Asynchronously? Names must communicate temporal behavior clearly.
send() vs sendAsync(): If your language doesn't enforce async in the type system, suffix async operations. Or use the type: send(): Promise<Result> makes async nature explicit.delete() vs scheduleDelete() vs queueForDeletion(): If the action doesn't happen immediately, say so. Never use an immediate-sounding verb for a deferred action.loadUsers() suggests immediate loading. If loading is deferred until access, use lazyLoadUsers() or return a LazyCollection<User> that makes laziness type-visible.read() vs readBlocking() vs tryRead(): In environments where blocking vs non-blocking matters, encode it in the name. tryRead() conventionally doesn't block.notify() that returns void suggests fire-and-forget. If you need to await completion, notifyAsync(): Promise<void> or notifyAndWait() makes this clear.123456789101112131415161718192021222324252627282930313233343536373839404142
// ❌ Temporal confusion class EmailService { // Does this send immediately or queue? send(email: Email): void { this.queue.add(email); // Surprise! Doesn't send immediately } // Does this block until message is received? receiveMessage(): Message { return this.blockingQueue.take(); // Surprise! Blocks forever }} // ✅ Temporal clarity class EmailService { // Name clarifies: queued, not sent queueForDelivery(email: Email): void { this.queue.add(email); } // Name clarifies: sends immediately, async async sendImmediately(email: Email): Promise<DeliveryResult> { return await this.smtpClient.send(email); } // Name clarifies: blocking operation receiveMessageBlocking(): Message { return this.blockingQueue.take(); } // Name clarifies: non-blocking, may not have message tryReceiveMessage(): Message | null { return this.queue.poll(); } // Name clarifies: async with timeout async receiveMessageAsync(timeout: Duration): Promise<Message | null> { return await this.queue.pollAsync(timeout); }}In languages with rich type systems, let types communicate timing. Promise<T> says 'async'. Observable<T> says 'stream of values over time'. Lazy<T> says 'computed on demand'. Types are harder to ignore than name suffixes.
Method names get the most attention, but parameter and return value names also set expectations. Poorly named parameters cause misuse; unclear return semantics cause bugs.
Parameter Naming Principles:
1. Name for Role, Not Type
// ❌ Named for type
function transfer(string1: string, string2: string, number1: number)
// ✅ Named for role
function transfer(sourceAccount: string, targetAccount: string, amount: number)
2. Clarify Units
// ❌ What unit is timeout?
function connect(timeout: number)
// ✅ Unit is explicit
function connect(timeoutMs: number)
function connect(timeout: Duration)
3. Clarify Semantics of Common Types
// ❌ Is this inclusive or exclusive? 0-indexed?
function getRange(start: number, end: number)
// ✅ Names clarify semantics
function getRange(startIndexInclusive: number, endIndexExclusive: number)
12345678910111213141516171819202122232425262728293031323334353637
// ❌ Parameter naming that causes bugs function schedule( callback: () => void, time: number // Seconds? Milliseconds? Absolute time?): void; function copy( src: string, // Source or destination first? dst: string): void; function between( a: number, // Inclusive? Exclusive? b: number, value: number // Is value the thing being tested or a bound?): boolean; // ✅ Parameter naming that prevents bugs function scheduleAfterMs( callback: () => void, delayMilliseconds: number): CancellationToken; function copyFromTo( sourcePath: string, destinationPath: string, options?: CopyOptions): Promise<void>; function isValueBetween( value: number, lowerBoundInclusive: number, upperBoundExclusive: number): boolean;Return Value Semantics:
| Return Pattern | What It Signals | Expectation |
|---|---|---|
T | Operation succeeded; value returned | Never null/undefined |
| `T | null` | Value may not exist |
| `T | undefined` | Value may not be set |
Optional<T> | Explicit optionality | Use unwrap patterns |
Result<T, E> | Operation may fail | Caller handles error case |
Promise<T> | Async operation | Await or chain |
void | No return value | Side effect only |
| Collection | Zero or more results | Empty if none; never null |
Never use null to mean two different things. If null means 'not found', don't also use it to mean 'explicitly set to nothing'. Use Optional<T> or separate methods: find() returns null if not found; getRequired() throws if not found.
Class and module names set expectations about responsibility and behavior. A well-named class tells you what it does; a poorly-named class tells you nothing—or worse, lies.
UserManager could do anything related to users. Prefer specific names: UserAuthenticator, UserPermissions, UserProfileEditor.UserServiceImpl says nothing about how it differs from other implementations. Name for distinction: CachedUserService, RemoteUserService.StringFormatting, DateParsing, ValidationRules.AbstractUserRepository tells you it's abstract, not what it does. The abstract nature is in the language (abstract class). Name for purpose: UserRepository is fine.UserAccountProfilePictureUploadValidationService is too specific and rigid. If the class does one thing, a shorter name suffices.Class Naming Patterns That Work:
| Pattern | Example | What It Signals |
|---|---|---|
*Service | PaymentService | Orchestrates business operations |
*Repository | UserRepository | Data access; CRUD operations |
*Factory | ConnectionFactory | Creates instances |
*Builder | QueryBuilder | Fluent construction |
*Validator | EmailValidator | Validates; returns boolean or errors |
*Adapter | LegacyApiAdapter | Translates between interfaces |
*Decorator | LoggingDecorator | Wraps and enhances |
*Strategy | PricingStrategy | Encapsulates algorithm |
*Specification | ActiveUserSpec | Encapsulates business rule |
The best class names come from the domain, not from technical patterns. Instead of OrderProcessor, use domain terms: OrderFulfillment, ShipmentCoordinator, InvoiceGenerator. Domain names communicate intent to business stakeholders and connect code to requirements.
12345678910111213141516171819202122232425262728293031323334
// ❌ Vague class names class UserHelper { validate(user: User): boolean; format(user: User): string; sendEmail(user: User, template: string): void; checkPermissions(user: User, resource: Resource): boolean; // What is this class's responsibility? Everything?} class DataProcessor { process(data: unknown): unknown; // What does this even do?} // ✅ Focused, intention-revealing names class UserValidator { validate(user: User): ValidationResult;} class UserProfileFormatter { formatForDisplay(user: User): FormattedProfile; formatForExport(user: User): ExportableProfile;} class WelcomeEmailSender { sendWelcomeEmail(user: User): Promise<DeliveryStatus>;} class ResourceAccessChecker { canAccess(user: User, resource: Resource): boolean; getAccessibleResources(user: User): Resource[];}Good naming is a skill that improves with practice. Here are techniques for developing your naming sense:
calculateTotalPriceWithTax().if (user.isActive && !user.isDeleted) reads naturally. if (user.activeFlag && user.deletedIndicator == false) doesn't. Good names read like prose.get*, don't introduce fetch* or retrieve* without reason.process() could mean ten different things, it's not precise enough.Modern IDEs make renaming nearly free. There's no excuse for keeping a bad name because 'it would be too much work to change'. If you discover a name is misleading, rename it immediately. The cost of confusion far exceeds the cost of a refactoring.
We've explored the profound relationship between naming and the Principle of Least Astonishment. Let's consolidate the key insights:
get*(), is*(), create*() all have implied semantics. Respect them.What's next:
Naming matters at every level, but it's especially critical in API design. The final page of this module explores API design and astonishment—how to design interfaces (whether library APIs, REST endpoints, or public contracts) that users can predict, trust, and use without reading the documentation.
You now understand how naming choices create or violate the Principle of Least Astonishment. Every name you write makes a promise—make sure your code keeps that promise. Next, we'll apply these insights to the highest-stakes naming context: API design.