Seven Habits That Make Code Easier for the Next Developer to Change
Guard clauses, intent-revealing names, API boundaries, precise types, pure decision functions, structured errors and focused pull requests, with JavaScript examples.
This article is published in English.
Sources: 7 Coding Habits That Separate Senior Developers From the Rest
Learning another framework eventually stops making you a noticeably better engineer. What keeps paying off is the way you write and change code: experienced developers often produce less code, with clearer intent, that is easier to read, test and modify when requirements shift. This article walks through seven concrete habits, each with a before-and-after JavaScript or TypeScript example, and explains the specific cost each one removes.
None of these habits requires a new tool. They are decisions you make while typing a function, naming a variable, modelling data or opening a pull request.
1. Keep the happy path visible with guard clauses
Suppose a function has to look up a user, confirm the account is active, confirm the user has permission, and only then perform an action. A natural first draft nests one condition inside another:
function performAction(userId) {
const user = findUser(userId);
if (user) {
if (user.isActive) {
if (user.hasPermission) {
return executeAction(user);
}
}
}
return null;
}
It works, but the operation that actually matters sits three levels deep, and the fallback return null at the bottom is visually disconnected from the conditions that lead to it.
The alternative is to reject invalid cases immediately with guard clauses, so each failed precondition exits on its own line. (The indentation in this snippet is uneven; all four statements after the lookup belong at the same level.)
function performAction(userId) {
const user = findUser(userId);
if (!user) return null;
if (!user.isActive) return null;
if (!user.hasPermission) return null;
return executeAction(user);
}
Now the main path reads straight down: check, check, check, act. This does not mean nested logic is always wrong. When branches represent genuinely different outcomes rather than preconditions, an if/else can be the clearest form. The goal is to stop essential business logic from being buried under conditions that are really just gatekeeping.
The habit: deal with exceptional cases first and leave the normal flow unindented. Someone opening the function should see what usually happens without tracking five levels of braces. A side benefit is that each guard can later return a more specific result or error, which is hard to do cleanly in the nested version.
2. Name things for what they mean
Few changes improve readability as cheaply as better names. Look at this pair of lines:
const data = getOrder();
process(data);
What is data, and what does process do to it? You have to go and read other code to find out. Compare:
const pendingOrder = getPendingOrder();
processOrder(pendingOrder);
The same two lines now tell you which kind of order is involved and what happens to it. Good names reduce the mental effort required to understand a codebase, because the reader no longer has to reconstruct meaning from context.
Function names deserve the same care. A generic verb like this one hides the behavior:
handle(order);
while this version states it:
calculateOrderTotal(order);
Likewise, a bare send leaves open what is being sent and to whom:
send(order);
and a descriptive name answers both questions:
sendOrderConfirmation(order);
Long names are not the goal; names that communicate intent are. A useful side effect is that vague names often expose vague functions. If handle is hard to rename, it may be doing several unrelated things and deserve to be split.
The habit: before settling on a name, ask whether someone reading only that line, without looking anywhere else, would understand what the value or function represents. If not, keep looking for a better name.
3. Keep external systems behind a boundary
Applications talk to many outside systems: payment providers, email services, cloud storage, identity providers, third-party APIs and internal microservices. A frequent mistake is letting their response formats flow straight into business code:
const customer = await stripeCustomer.get();
console.log(customer.email_address);
console.log(customer.customer_name);
Every line that reads email_address or customer_name now depends on the vendor's exact field names. If the provider renames a field or restructures its response, the change ripples through the codebase.
The fix is a thin boundary function that calls the external service and translates the response into a model your application owns:
async function getCustomer(customerId) {
const response = await externalService.getCustomer(customerId);
return {
email: response.email_address,
name: response.customer_name
};
}
The rest of the code uses that internal shape and never sees the vendor's naming:
const customer = await getCustomer(customerId);
console.log(customer.email);
console.log(customer.name);
When the external API changes, only the mapping inside getCustomer needs updating. The same boundary is also the natural place to validate the response, normalize missing values and swap in a fake during tests. This is the idea behind adapters and anti-corruption layers in larger architectures, applied at the scale of a single function.
The habit: keep external complexity at the edges. Business logic should not need to know the quirks of every third-party service, and that separation becomes more valuable as the number of integrations grows.
4. Make invalid states hard to represent
A tempting way to define a model is to make every field optional, because it is quick and never fails to compile:
type Order = {
id?: string;
paymentId?: string;
status?: string;
};
The cost appears everywhere the model is used. Each consumer has to check defensively, because nothing guarantees any field is present:
if (order.id) {
// ...
}
if (order.paymentId) {
// ...
}
if (order.status === "paid") {
// ...
}
The deeper problem is not verbosity. The type says nothing about which combinations of fields are valid. An order with a payment ID but no ID, or a status of "paid" with no payment, are all legal as far as the compiler is concerned.
A more precise model describes a specific state:
type PaidOrder = {
id: string;
paymentId: string;
status: "paid";
};
A value of type PaidOrder guarantees three things:
- it has an ID
- it has a payment ID
- its status is exactly
"paid"
The type now doubles as documentation and as a compile-time safety net. In practice you would define one type per state, such as a pending order without a payment ID and a paid order with one, and combine them into a discriminated union on status. TypeScript can then narrow the type automatically after a check like order.status === "paid", and warn you when a new state is not handled. For a worked example of the pattern, see replacing loading flags with a discriminated union.
The habit: do not default to optional fields just because they make the first version easier to write. Ask which states should actually be possible, and shape your types around them. This will not eliminate every bug, but it makes a whole class of incorrect data much harder to create.
5. Separate decisions from actions
Business rules become hard to test when they are tangled with side effects. Take a feature that only verified adult users may use:
async function processUser(userId) {
const user = await database.getUser(userId);
if (user.isVerified && user.age >= 18) {
await database.enableFeature(userId);
await emailService.send(user.email);
}
}
The rule itself, verified and at least 18, is mixed together with a database read, a database write and an outgoing email. Testing the rule means mocking all three.
Pull the decision out into a pure function that takes data and returns a boolean:
function canUseFeature(user) {
return user.isVerified && user.age >= 18;
}
The orchestration function then fetches the user, asks the rule, and performs the side effects only when it passes:
async function processUser(userId) {
const user = await database.getUser(userId);
if (!canUseFeature(user)) {
return;
}
await database.enableFeature(userId);
await emailService.send(user.email);
}
The rule can now be tested directly, with a plain object:
expect(
canUseFeature({
isVerified: true,
age: 25
})
).toBe(true);
No database, no email service, no mocks: only the decision and its inputs. That makes it trivial to cover edge cases such as an unverified 30-year-old or a verified user who is exactly 18.
The habit: separate "should we do this?" from "how do we do it?". Good candidates for this split include:
- access and permission checks
- price calculations
- input validation
- deciding whether to notify someone
- retry policies
- eligibility and feature-flag rules
The more a rule matters to the business, the more it benefits from living in a small, pure, well-tested function.
6. Make errors useful to people and to software
An error like this one gives almost nothing to work with:
throw new Error("Something went wrong");
It does not say what failed, where, why, or whether the caller can do anything about it.
A better error carries a stable code alongside a human-readable message. The example assumes an AppError class that extends Error and stores both values; it is not a built-in.
throw new AppError(
"PAYMENT_FAILED",
"The payment could not be completed."
);
Serialized for an API response, it gives clients something predictable:
{
"code": "PAYMENT_FAILED",
"message": "The payment could not be completed."
}
The distinction worth remembering: messages are written for humans, codes are written for systems. Messages may be reworded or translated, but code values should stay stable so clients can branch on them safely. A frontend, for example, can react to the specific failure:
if (error.code === "PAYMENT_FAILED") {
showPaymentError();
}
Logs deserve the same treatment. A bare message is hard to act on at 3 a.m.:
console.log("Payment failed");
Structured context makes the entry searchable and diagnosable:
logger.error("Payment failed", {
orderId,
provider,
attempt
});
Be deliberate about what goes into that context. Never log:
- passwords
- access tokens
- API keys
- other secrets
- sensitive personal data
If you need a standard shape for HTTP error bodies, RFC 9457 problem details is worth a look.
The habit: give every failure a form that a person can read and a program can act on. Throwing is the easy part; the real work is making failures predictable and quick to diagnose.
7. Keep each change focused
The last habit concerns changing code rather than writing it. Picture a single pull request that contains:
- a new checkout feature
- a database migration
- a refactor of the payment service
- frontend updates
- changes to retry logic
- several unrelated bug fixes
Even if all of it works, it is very hard to review well. Reviewers skim, risks hide in the noise, and if something breaks in production, rolling back one part means rolling back everything.
Split into focused changes, the same work looks like this:
PR #1 → Add checkout validation
PR #2 → Refactor payment service
PR #3 → Update checkout UI
PR #4 → Improve payment retry logic
Each one is easier to:
- review
- test
- debug
- revert
- understand months later from the history
Focused changes also help collaboration: when a regression appears, the area to investigate is small, and tools such as git bisect point at a change that is meaningful on its own.
The habit: before folding another change into the current task, ask whether it belongs to the same problem. If it does not, give it its own commit or pull request. A focused change is easier to trust than a huge one that happens to pass the tests.
The common thread: make the next change easier
These habits look unrelated at first, but they share a single goal. Good engineering is not only about making today's code work; it is about making tomorrow's modification less painful. Each habit contributes a piece:
- a visible happy path makes logic easier to follow
- meaningful names make intent obvious
- boundaries around external systems contain the impact of their changes
- precise types rule out whole categories of bugs
- separating decisions from actions makes business rules testable
- structured errors make failures diagnosable
- focused changes keep the codebase easy to evolve
Frameworks and libraries come and go, but these principles apply equally in JavaScript, TypeScript, Python, Java, Go or anything else.
Key takeaways
- When you finish a function, a model, an integration or a pull request, ask not only whether it works but whether the next developer can change it safely.
- Guard clauses, precise names and precise types reduce how much a reader must hold in their head.
- Boundaries and pure decision functions limit how far a change can spread and make behavior cheap to test.
- Stable error codes and structured, secret-free logs turn failures into information.
- Small, single-purpose changes are easier to review, revert and trust.