Skip to main content

Six Rules That Help You Write Clean Code

Published on 7 min read

Clean code is code you can verify by reading it. The next person opens a file, follows what it does, and changes it without fear. That next person is usually you, six months from now.

Most advice about clean code comes back to six rules. You've probably heard all of them. The problem is that each one is easy to apply too hard, and an over-applied rule makes code worse. So for each rule, here's what it means, how I apply it, and where I stop.

Separation of Concerns (SOC)

Break a program into smaller units, each focused on one task.

The split I care about most is between decisions and actions. A decision is a pure function: it takes data and returns an answer. An action does I/O: it fetches, writes to the DOM, sets state, shows a toast. When the two are mixed, you can't test the decision without faking the world around it.

mixed-concerns.ts
1const showInvoice = async (id: string) => {
2  const response = await fetch(`/api/invoices/${id}`);
3  const data = await response.json();
4  const total = data.items.reduce((sum, item) => sum + item.price * item.qty, 0);
5  document.querySelector('#total')!.textContent = `$${total.toFixed(2)}`;
6};

Split it by responsibility, and keep the pieces in the same file:

invoice.ts
1const invoiceSchema = z.object({
2  items: z.array(z.object({ price: z.number(), qty: z.number().int() })),
3});
4
5const currencyFormatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
6
7// Decision: pure, tested without mocks
8export const calculateTotal = (items: Array<{ price: number; qty: number }>) =>
9  items.reduce((sum, item) => sum + item.price * item.qty, 0);
10
11// Action: talks to the network and the DOM
12export const showInvoice = async (id: string) => {
13  const response = await fetch(`/api/invoices/${id}`, { signal: AbortSignal.timeout(10_000) });
14  const invoice = invoiceSchema.parse(await response.json());
15
16  const totalElement = document.querySelector('#total');
17  if (!totalElement) throw new Error('showInvoice: #total element is missing from the page');
18
19  totalElement.textContent = currencyFormatter.format(calculateTotal(invoice.items));
20};

calculateTotal now runs in a test with no network and no browser. The API response is parsed at the edge, so a bad payload fails loudly at the line that received it instead of somewhere downstream.

Where I stop: separating concerns is not the same as separating files. I keep a budget: to understand a function, you should open at most two other files. Shared infrastructure everyone knows (the HTTP client, the UI primitives) doesn't count. If tracing a value takes five file jumps, the structure is wrong, no matter how "separated" it looks. One long, cohesive file reads better than five short ones.

Document Your Code (DYC)

Write code for your future self and for others.

Most documentation should live in the code itself:

  • Names say the business meaning. isEligibleForRenewal, not checkFlag2. A good name removes the need for a comment.
  • Comments say why, never what. The code already says what. A comment earns its place when it explains a constraint you can't see.
  • Files read top to bottom. Private helpers go above the export that uses them, so nothing is read before it's defined.
comments.ts
1// Bad: restates the code
2// Increment retries by 1
3retries += 1;
4
5// Good: explains a constraint you can't see in the code
6// The payment provider rate-limits at 5 requests per second per merchant.
7await sleep(250);

There's one kind of comment I replace with code every time: the assumption. A comment can't fail. A check can.

assumptions.ts
1// Bad: the assumption lives in a comment
2// cart always has at least one item here
3const firstItem = cart.items[0];
4
5// Good: the assumption is checked
6const [firstItem] = cart.items;
7if (!firstItem) throw new Error(`checkout: cart ${cart.id} has no items`);

Where I stop: comments that drift. A wrong comment is worse than no comment, so they change with the code or they get deleted.

Don't Repeat Yourself (DRY)

Don't write the same thing twice. Use functions, modules, and existing libraries.

DRY is right about rules. If "free shipping starts at $50" lives in three places, a price change needs three edits and one will be missed. A business rule gets one home and a name:

shipping.ts
1const FREE_SHIPPING_THRESHOLD = 50;
2
3export const isEligibleForFreeShipping = (subtotal: number) => subtotal >= FREE_SHIPPING_THRESHOLD;

DRY is wrong about code that looks alike. Two blocks with the same shape but different reasons to exist will drift apart. Merge them early, and the shared function grows a flag for the second caller, then an options object for the third.

wrong-abstraction.ts
1// One function pretending to be two
2const buildEmail = (user: User, options: { isWelcome?: boolean; order?: Order }) => {
3  if (options.isWelcome) return { to: user.email, subject: `Welcome, ${user.name}` };
4  if (options.order) return { to: user.email, subject: `Order ${options.order.id} confirmed` };
5  throw new Error('buildEmail: pass isWelcome or order');
6};
7
8// Two functions, each obvious
9const buildWelcomeEmail = (user: User) => ({ to: user.email, subject: `Welcome, ${user.name}` });
10const buildOrderEmail = (user: User, order: Order) => ({ to: user.email, subject: `Order ${order.id} confirmed` });

My rule: if a function needs a flag, an options bag, or a type parameter to serve two callers, it's two functions. Two similar blocks are fine. I only unify when a third identical case shows up. I wrote more about this in Duplication Is Far Cheaper Than the Wrong Abstraction.

Where I stop: existing libraries are still the best kind of DRY. Date parsing, validation, and number formatting are solved problems. Use the solution.

Keep It Simple, Stupid (KISS)

Simple is hard, but better. Readable code beats clever code.

My test is short: if a reviewer has to think to see that it works, rewrite it.

kiss.ts
1// Clever
2const label = score < 34 ? 'Low' : score < 68 ? 'Medium' : 'High';
3
4// Simple
5const getRiskLabel = (score: number) => {
6  if (score < 34) return 'Low';
7  if (score < 68) return 'Medium';
8  return 'High';
9};

A few habits keep code simple:

  • Guard clauses first. Validate and return early, so the happy path stays unindented. No else after a return, no nested ternaries.
  • Call the function you mean. One level of indirection, no more. No factories that return functions, no wrappers that wrap behavior, no barrel files that re-export. "Where is this defined?" should be a one-jump question.
  • Every loop and retry has a bound. No while (true). A retry has a max attempt count, every request has a timeout.
  • A function fits on one screen. Around 60 lines at most. Longer than that, split it by responsibility into private pieces in the same file.

Where I stop: simple doesn't mean skipping structure the problem needs. It means no structure the problem doesn't need.

Test Driven Development (TDD)

TDD is a short loop:

  • Red: write a failing test first.
  • Green: write just enough code to make it pass.
  • Refactor: clean up the code without changing its behaviour.
invoice.test.ts
1import { describe, expect, it } from 'vitest';
2import { calculateTotal } from './invoice';
3
4describe('calculateTotal', () => {
5  it('should return 0 for an empty invoice', () => {
6      expect(calculateTotal([])).toBe(0);
7  });
8
9  it('should multiply price by quantity', () => {
10      expect(calculateTotal([{ price: 10, qty: 3 }])).toBe(30);
11  });
12});

Writing the test first forces you to decide what the code does before deciding how. And the refactor step, where the actual cleaning happens, is only safe because the tests exist.

This is where separation of concerns pays off. I test decisions: parsers, calculators, mappers, query builders. They're pure, so the tests need no mocks. When a test needs a pile of mocks, the decision and the action are usually still tangled together.

Where I stop: I don't unit-test thin wiring, like a component that passes props to a button. Test what the code decides, not how it's assembled, so you can change the assembly without rewriting every test.

You Ain't Gonna Need It (YAGNI)

Build what you need now. Don't build features you think you might need later.

Every "just in case" parameter is code you read, test, and maintain for a requirement that may never arrive:

yagni.ts
1// Built for a future that hasn't happened
2const formatPrice = (
3  amount: number,
4  options: { currency?: string; locale?: string; showSymbol?: boolean } = {},
5) => { /* ... */ };
6
7// Built for what the app does today
8const priceFormatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
9
10const formatPrice = (amount: number) => priceFormatter.format(amount);

If the app goes multi-currency next year, you add the parameter then, with a real use case telling you what it should look like.

YAGNI also applies to where code lives. Declare everything at the smallest scope that works: a value in the block that uses it, a helper in the file that uses it, state in the lowest component that owns it, a file in the feature that uses it. Promote it one level only when a second consumer actually appears, not when you imagine one.

Where I stop: decisions that are expensive to reverse. You don't need a plugin system on day one, but a database schema and a public API deserve some thought up front.

How the Rules Fit Together

The rules lean on each other:

  • SOC makes TDD cheap. Pure decisions are tested without mocks.
  • TDD makes refactoring safe. With tests in place, you can simplify without breaking things.
  • YAGNI keeps DRY honest. An abstraction built for callers that don't exist yet is the wrong abstraction waiting to happen.
The one rule under all six

Write code you can verify by reading it. If following one of these rules makes the code harder to read, the rule is being applied wrong.

All articles