Clean functions are not about aesthetics — they are about the cost of change. A function that does one clear thing can be read in 30 seconds, tested in isolation, and modified without fear. A function that does five things requires understanding all five, breaking any of them fails silently, and every change is a gamble.
What changed in 2026
- AI-assisted code review flags complexity automatically. Tools like GitHub Copilot and Cursor highlight cyclomatic complexity > 10 inline. But they generate complex functions too, so the discipline matters more, not less.
- TypeScript is the default. Strong types surface bad function signatures before runtime; they make "what does this function accept and return" an answered question.
- Functional patterns are mainstream. Immutability, pure functions, and composable pipelines are now taught in onboarding, not just FP circles.
- LLMs struggle with tangled functions. If your function is hard for a model to reason about, it is hard for humans too — use that as a code smell signal.
The single-responsibility rule
A function should do exactly one thing at one level of abstraction. The clearest test: can you describe it without the word "and"?
// Bad — two jobs: validation AND transformation
function processEmail(email: string): User {
if (!email.includes("@")) throw new Error("invalid");
return { email: email.toLowerCase().trim() };
}
// Good — separate concerns
function validateEmail(email: string): void {
if (!email.includes("@")) throw new Error("invalid email");
}
function normaliseEmail(email: string): string {
return email.toLowerCase().trim();
}
Naming
| Pattern |
Example |
Why it works |
| Verb + noun |
fetchUser, sendInvoice |
Describes the action clearly |
is/has/can prefix |
isValid, hasPermission |
Signals boolean return |
get vs fetch |
getFullName (sync), fetchUser (async) |
Signals I/O vs computation |
| Avoid |
handle, process, util |
Too vague — anything fits |
Never abbreviate unless the abbreviation is universal (url, id, i in a loop).
Length and complexity
// Cyclomatic complexity: every branch +1
// Target < 5; reject > 10
// Bad: 8 branches in one function
function calculateDiscount(user, cart, coupon) {
if (!user) return 0;
if (cart.total < 10) return 0;
if (user.isPremium) {
if (coupon) return 0.3;
return 0.2;
}
if (coupon && coupon.valid) return 0.1;
if (cart.items > 5) return 0.05;
return 0;
}
// Better: extract named helpers
function getPremiumDiscount(hasCoupon: boolean): number {
return hasCoupon ? 0.3 : 0.2;
}
function getStandardDiscount(coupon: Coupon | null, itemCount: number): number {
if (coupon?.valid) return 0.1;
if (itemCount > 5) return 0.05;
return 0;
}
Pure functions
A pure function: same inputs → same output, no observable side effects.
// Impure — mutates external state
let count = 0;
function increment() { count++; }
// Pure — returns new value
function increment(n: number): number { return n + 1; }
// Impure — reads external DB inside
async function getUser(id: string) {
return db.find(id); // side effect!
}
// Better: inject the dependency
async function getUser(id: string, db: Database): Promise<User> {
return db.find(id);
}
Injecting dependencies makes functions pure relative to their contract and trivially testable.
How to pick the right abstraction level
// Mixed levels — bad
async function createOrder(items: Item[], userId: string) {
const user = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
// ... low-level SQL mixed with business logic
const total = items.reduce((s, i) => s + i.price, 0);
await sendEmail(user.email, `Order total: ${total}`);
}
// Consistent level — good
async function createOrder(items: Item[], userId: string) {
const user = await fetchUser(userId); // same level
const order = buildOrder(items, user); // same level
await persistOrder(order); // same level
await notifyUser(user, order); // same level
}
Each line in the good version reads like a sentence in the same paragraph.
Common mistakes
Boolean flag parameters. render(true) is unreadable at the call site. Split into renderWithHeader() and renderWithoutHeader(), or use an options object with a named key.
Output arguments. Functions that modify a passed-in object are confusing. Return the modified value instead.
Deeply nested logic. Every level of indentation is a tax on the reader. Use early returns (guard clauses) to flatten.
// Nested — hard to read
function process(user) {
if (user) {
if (user.active) {
if (user.hasPermission) {
doWork();
}
}
}
}
// Flat — easy to read
function process(user) {
if (!user) return;
if (!user.active) return;
if (!user.hasPermission) return;
doWork();
}
Dead parameters. If a parameter is never used inside the function, remove it. It misleads every caller.
What to skip
- Docblock comments for obvious functions.
// Returns the user name above getUserName() adds noise. Write self-documenting code; save comments for why, not what.
- Premature abstraction. Don't extract a helper until you've written the duplication twice. "Rule of Three" still holds.
- Classes for stateless logic. A module of exported functions is simpler than a class with no instance state.
FAQ
How long should a function really be?
15–25 lines is the practical sweet spot. Past 40 lines, break it up. Past 60 lines, you almost certainly have multiple responsibilities.
Should every function be pure?
No — I/O, DB writes, and UI updates are inherently effectful. The goal is to push effects to the edges and keep the core logic pure.
What about performance — does splitting functions add overhead?
Negligible in modern runtimes. V8, the JVM, and LLVM inline small functions aggressively. Write for humans first.
How do I refactor a 200-line function without breaking it?
Add tests first (even characterisation tests that just capture current output), then extract the smallest identifiable sub-task into a named function and re-run tests after each extraction.
Where to go next