Clean Functions
F1: Too Many Arguments (Maximum 3)
// Bad - too many parameters
function createUser(
name: string,
email: string,
age: number,
country: string,
timezone: string,
language: string,
newsletter: boolean
) {
// ...
}
// Good - use a typed object
type UserData = {
name: string;
email: string;
age: number;
country: string;
timezone: string;
language: string;
newsletter: boolean;
};
function createUser(data: UserData) {
// ...
}
More than 3 arguments means your function is doing too much or needs a data structure.
F2: No Output Arguments
Don't modify arguments as side effects. Return values instead.
type Report = {
content: string;
};
// Bad - modifies argument
function appendFooter(report: Report): void {
report.content += "\n---\nGenerated by System";
}
// Good - returns new value
function withFooter(report: Report): Report {
return {
...report,
content: `${report.content}\n---\nGenerated by System`,
};
}
F3: No Flag Arguments
Boolean flags mean your function does at least two things.
// Bad - function does two different things
function render(isTest: boolean) {
if (isTest) {
renderTestPage();
} else {
renderProductionPage();
}
}
// Good - split into two functions
function renderTestPage() {}
function renderProductionPage() {}
F4: Delete Dead Functions
If it's not called, delete it. No "just in case" code. Git preserves history.
The Stepdown Rule: Code Reads Top-Down
A module should read like a narrative: the highest level of abstraction first, each function followed by the ones it calls. The reader descends one level at a time and can stop as soon as they know enough.
// Bad - the reader meets escaping details before knowing what the module does
function escapeQuotes(value: string): string { ... }
function serialiseRow(row: Row): string { ... }
export function exportOrders(orders: Order[]): string { ... } // the point, buried
// Good - exported entry point first, helpers underneath in call order
export function exportOrders(orders: Order[]): string {
return orders.map(serialiseRow).join("\n")
}
function serialiseRow(row: Row): string {
return row.fields.map(escapeQuotes).join(",")
}
function escapeQuotes(value: string): string {
return value.replaceAll('"', '""')
}
Two consequences follow:
One level of abstraction per function. A function mixing orchestration with string escaping has no place in the ordering, because it belongs at two levels at once. That is the signal to split it.
// Bad - policy and character-level detail in one breath
export async function publish(post: Post): Promise<void> {
if (post.author.isBanned) return
const body = post.body.replace(/\r\n/g, "\n").trim().slice(0, 5000)
await db.posts.insert({ body })
}
// Good - every line is the same size of idea
export async function publish(post: Post): Promise<void> {
if (!isPublishable(post)) return
await store(post.author, normalise(post.body))
}
Vertical distance tracks relatedness. Callee directly below caller. Declarations close to first use. Exported functions before the module-private helpers they call — a reader opening the file sees the public surface first. In a React component, the component comes before its hooks-derived helpers, not after.
If a file cannot be ordered this way, it holds more than one responsibility and wants splitting.