open atlas
↑ Back to track
Code patterns & craft CP · 02 · 04

Arguments and side effects

A function's parameter list and its hidden effects are honest signals of its cohesion — 0–2 arguments are ideal, flag and output arguments mean it does two jobs, and side effects buried behind an innocent name are the costliest lie a signature can tell.

CP Middle ◷ 19 min
Level
FoundationsJuniorMiddleSenior

You call getUser(id) and it works. Later you discover that, somewhere inside, it also writes a row to an audit table and bumps a Redis counter. The name said get; the body got, logged, and mutated. Now a test that “just reads a user” needs a database and a Redis instance, and a caller who wanted the user twice in a loop has accidentally written twelve audit rows. Nothing was broken — until you tried to reuse it.

A function’s signature is a contract you read far more often than you read its body. When the argument list is long, when an argument is a boolean flag, or when the real work happens off to the side and never shows up in the signature, the contract is lying. This lesson is about reading those lies — because each one is a cohesion problem wearing a parameter-list disguise.

Goal

After this lesson you can justify the 0–2–3–4 argument heuristic in cost-of-change terms; recognise a boolean flag argument as a “this function does two things” signal and split it; refuse output arguments in favour of return values; and tell apart a function whose side effects are explicit in its name and shape from one that hides them — including the case where a parameter object just relocates the smell instead of curing it.

1

Aim for 0–2 arguments; treat 3 as needing a reason and 4+ as a missing concept. Each parameter is something the reader must hold in their head, supply correctly, and keep in the right order. A niladic (zero-arg) or monadic (one-arg) call is trivial to read and to test; a triadic call already forces you to remember which boolean was validate and which was notify. This is not a hard cap — clamp(value, min, max) is a fine, cohesive triad. The heuristic is a smell threshold: at four arguments, ask what concept the call site is reassembling by hand every time.

// 5 positional args: order-dependent, unreadable at the call site
createOrder(customerId, items, 'express', true, null);
//                              ^ shipping  ^ gift  ^ coupon — which is which?

When several parameters always travel together, they are not five inputs — they are one idea (an order request) that has not been named yet.

2

Collapse a long, related parameter list into a parameter object. When parameters cluster — (x, y, width, height), (street, city, zip, country), the five order fields above — introduce a type that names the cluster. The call site becomes self-documenting because each value is labelled, order stops mattering, and adding a sixth field is a change to the type, not to every call site’s positional list.

interface OrderRequest {
  customerId: string;
  items: LineItem[];
  shipping: ShippingSpeed;
  gift: boolean;
  coupon?: string;
}

createOrder(req: OrderRequest): Order { /* ... */ }

// call site: labelled, order-free, extensible
createOrder({ customerId, items, shipping: 'express', gift: true });

The win is conditional: it only counts when the fields are a genuine concept. Bagging unrelated parameters buys nothing — see the failure inset.

3

A boolean flag argument almost always means the function does two things — split it. render(item, isAdmin) or save(user, true /* sendEmail */) reads as: the body has an if (flag) that selects between two behaviours. That is two responsibilities sharing one name, and the call site save(user, true) is unreadable without hunting down the parameter. The fix is to make the two behaviours two named functions.

// before: one function, a hidden fork, an opaque call site
function save(user: User, sendEmail: boolean) {
  db.write(user);
  if (sendEmail) mailer.welcome(user);
}
save(user, true);  // true... what is true?

// after: each behaviour named; intent is visible at the call site
function saveUser(user: User) { db.write(user); }
function registerUser(user: User) { saveUser(user); mailer.welcome(user); }
registerUser(user);  // reads as itself

A flag argument is low cohesion made visible in the signature: one function is being asked to be two. Splitting it raises cohesion and makes every call site state which thing it meant.

4

Don’t return results through arguments — return them. An output argument is a parameter the function mutates so the caller can read the result back out. It inverts the natural reading direction: arguments are supposed to be inputs, so appendFooter(report) looks like it consumes report, when in fact it edits it in place. The reader has to know the body to know who owns the mutation.

// output argument: report goes IN, comes back changed — surprising
function addTotals(report: Report): void {
  report.total = report.lines.reduce((s, l) => s + l.amount, 0);
}

// command–query split: a query returns, a command's effect is in its name
function totalFor(lines: Line[]): number {
  return lines.reduce((s, l) => s + l.amount, 0);
}
const report = { ...draft, total: totalFor(draft.lines) };

If the function computes something, return it. Mutating an argument hides the effect behind a parameter that looked like a plain input.

5

Make side effects explicit and named; a “pure-looking” function that secretly mutates the world is the costliest lie a signature tells. A name like parseConfig(text) promises a pure transform: text in, config out, no I/O. If it also writes a file or fires a network call, every caller inherits an effect they never asked for — tests need real infrastructure, retries duplicate the write, and reasoning about the program breaks. The rule is command–query separation: a function either answers a question (query, pure, returns a value) or changes the world (command, returns void/effect) — and its name says which.

// LIE: pure-looking name, hidden write
function parseConfig(text: string): Config {
  const cfg = JSON.parse(text);
  fs.writeFileSync('/var/cache/config.json', text); // surprise side effect!
  return cfg;
}

// HONEST: the query is pure; the command names its effect
function parseConfig(text: string): Config { return JSON.parse(text); }
function cacheConfig(text: string): void { fs.writeFileSync('/var/cache/config.json', text); }

The senior reading of all four signals — long lists, flags, output args, hidden effects — is the same: each is a cohesion smell surfacing in the interface. A function with one clear job needs few inputs, no behaviour-selecting flag, no smuggled output, and an effect (if any) that its name admits to.

Worked example

One call, three smells, fixed together. Here is a function that has accreted a long list, a flag, and a hidden effect:

// notify a user; returns nothing useful, hides a lot
function notify(
  userId: string,
  message: string,
  channel: string,
  urgent: boolean,
  retries: number,
): void {
  const user = db.users.find(userId);      // hidden read
  const body = urgent ? `URGENT: ${message}` : message;  // flag fork
  send(channel, user.email, body, retries);
  audit.log('notify', userId, channel);    // hidden write
}

notify('u_1', 'Build failed', 'email', true, 3);  // true? 3? what are these?

The call site is unreadable: true and 3 are positional mysteries, and the body quietly hits the database twice (a read and an audit write) behind a name that says only “notify”. The urgent flag forks behaviour, so this is really two notifications wearing one name.

Refactored — parameter object for the cluster, the flag split out, and the side effect named and lifted to the caller’s control:

interface Notification {
  user: User;          // caller resolves the user — the read is now explicit and reusable
  message: string;
  channel: Channel;
  retries?: number;
}

// query-ish command: one job (deliver), effect named, no hidden DB read
function deliver(n: Notification): DeliveryResult {
  return send(n.channel, n.user.email, n.message, n.retries ?? 3);
}

// the "urgent" behaviour is its own named function, not a flag
function deliverUrgent(n: Notification): DeliveryResult {
  return deliver({ ...n, message: `URGENT: ${n.message}` });
}

// auditing is a separate, visible command the caller composes when it wants it
const result = deliverUrgent({ user, message: 'Build failed', channel: 'email' });
audit.log('notify', user.id, 'email');

The call site reads as English, the database read is the caller’s explicit choice (and trivially mockable), urgency is a named function instead of a true, and auditing is a command the caller opts into rather than an ambush. What made all three fixable at once: each smell was the same low cohesion — one function doing delivery and formatting and persistence and auditing — leaking through the parameter list.

Why this works

Why is a hidden side effect worse than an honest one rather than just “different”? Because the cost lands on people who never read the body. A signature is a promise about what you must provide and what you get back; callers, reviewers, and test authors plan against the promise, not the implementation. When parseConfig secretly writes a file, every one of them plans wrong: the test author doesn’t mock a filesystem they didn’t know was touched, the caller who calls it twice double-writes, the reviewer approves a “pure” change that actually has I/O. An honest effect — a function named writeConfigCache returning void — costs nothing extra because the promise matches reality. The expensive part of a side effect is never the effect; it is the gap between what the name claims and what the code does.

Common mistake

The seductive failure mode of this lesson: you take the 5-argument call, wrap the arguments in { ... }, and declare victory — but the object is a junk drawer of unrelated flags ({ validate, sendEmail, dryRun, verbose, force }) rather than a real concept. You have not raised cohesion; you have relocated the smell from the parameter list into a bag, and arguably made it worse, because now the flags are optional and invisible and the function still does five things. A parameter object earns its keep only when the fields are a genuine, cohesive idea that travels together (an OrderRequest, an Address, a Rect). If the “object” is just a pile of behaviour toggles, the real fix is the same as for flags: split the function. Bagging the arguments treats the symptom (a long list) while ignoring the disease (one function doing many jobs).

Check yourself
Quiz

You see save(user, true) where the second arg toggles whether a welcome email is sent. A reviewer suggests 'wrap the args in an options object: save(user, { sendEmail: true })'. Through the cohesion lens, what is the better move and why?

Recap

A function’s parameter list and its effects are an honest readout of its cohesion. Aim for 0–2 arguments, justify three, and read 4+ as a missing concept — collapse a genuinely cohesive cluster into a parameter object. A boolean flag argument means the function does two things; split it into two named functions. Don’t smuggle results back through output arguments — return them, keeping queries pure and commands honest about their effect (command–query separation). Above all, make side effects explicit and named: a pure-looking function that secretly writes a file is the costliest lie a signature can tell, because the cost lands on everyone who trusts the name instead of reading the body. And beware the failure mode — an options object that becomes a junk drawer of unrelated flags merely relocates the smell. Every one of these is the same diagnosis surfacing in the interface: low cohesion, a function quietly doing more than one job.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.