If your function signature accepts more than two positional arguments, you are actively sabotaging your team's productivity.
In 2026, the mechanics of how data enters and exits your functions are no longer about basic syntax; they are the defining contract of your application's architecture. For years, we treated parameters as an ordered list and return values as a single primitive. In modern, highly distributed systems and massive monorepos, this approach is a liability. The industry has decisively shifted away from implicit execution toward strict, self-documenting data contracts.
If a developer has to command-click into a function to understand what it returns or what order the parameters should be in, your architecture is bleeding cognitive bandwidth.
The real-world cost of implicit contracts
I have seen critical database records corrupted because an engineer accidentally swapped two positional arguments updateStatus(userId, true, false) instead of updateStatus(userId, false, true). The function accepted the call without complaint. TypeScript saw two booleans in the right positions and raised no error. The database wrote the wrong state. The bug survived code review because at the call site, true, false are completely opaque; you cannot tell what either argument means without navigating into the function definition.
// What does this mean at the call site?
updateStatus(userId, true, false);
// You have to go here to find out
function updateStatus(userId, isActive, isVerified) { ... }
This is not a TypeScript problem. TypeScript can enforce types, but it cannot enforce meaning. Two booleans of the same type are interchangeable as far as the compiler is concerned. The only thing preventing the swap is human memory, and human memory fails at scale.
Equally dangerous is the legacy approach to return values. When a utility function unexpectedly throws an error instead of returning a predictable failure state, it creates invisible control flow branches. In a modern component tree or edge runtime, an unhandled thrown error does not just fail a single operation — it can bring down the entire rendering pipeline or crash the serverless function completely.
The RORO pattern: Receive an Object, Return an Object
The fix for positional argument fragility is a single architectural mandate: any function requiring more than two inputs must use named parameter destructuring. Receive an object, return an object.
// Legacy — positional, order-dependent, opaque at the call site
function createUser(name, email, role, isActive, sendWelcome) {
// ...
}
createUser('Alice', 'alice@co.com', 'admin', true, false);
// What is true? What is false? No idea without the definition.
// RORO — named, order-independent, self-documenting at the call site
function createUser({ name, email, role, isActive = true, sendWelcome = false }) {
// ...
}
createUser({
name: 'Alice',
email: 'alice@co.com',
role: 'admin',
isActive: true,
sendWelcome: false,
});
The call site is now its own documentation. sendWelcome: false is unambiguous. A future engineer adding a seventh parameter does not need to audit every call site in the codebase — they add a named key with a default, and every existing caller continues to work without modification.
The RORO pattern also makes refactoring safe. With positional arguments, changing parameter order silently breaks every call site that does not happen to catch the type mismatch. With named parameters, order is irrelevant — the contract is defined by key names, not positions.
Guarantee the contract with destructuring defaults
When destructuring parameters, always assign fallback defaults at the boundary. The function's internal logic should never have to handle undefined that is the parameter layer's job.
// Fragile — undefined leaks into the function body
function buildQuery({ filters, limit, offset }) {
return db.query({ where: filters, take: limit, skip: offset });
// What if limit is undefined? The ORM decides — not you.
}
// Resilient — the boundary enforces the contract
function buildQuery({ filters = {}, limit = 20, offset = 0 } = {}) {
return db.query({ where: filters, take: limit, skip: offset });
// Behaviour is deterministic regardless of what the caller omits.
}
Note the = {} at the end — this handles the case where the entire options object is omitted. Without it, calling buildQuery() with no arguments throws Cannot destructure property 'filters' of undefined. The outer default makes the function resilient to being called with zero arguments while maintaining clean internal logic.
Treat errors as return values
throw is the right tool for truly exceptional, unrecoverable situations: a network timeout, a file system failure, a corrupted environment. It is the wrong tool for expected business logic outcomes: a missing record, a validation failure, an unauthorised request. These are not exceptions — they are states your application knows how to handle.
When you use throw for expected failures, you force every consumer of your function to wrap it in a try...catch block just to survive normal execution. This spreads defensive boilerplate across the codebase and creates invisible control flow — the thrown error is not visible in the function's return type, so callers who forget the try...catch are not warned by the compiler.
// Legacy — throws for an expected state, forces defensive wrapping everywhere
async function getUser(id) {
const user = await db.users.findById(id);
if (!user) throw new Error('User not found');
return user;
}
// Calling code must guess this can throw
try {
const user = await getUser(id);
render(user);
} catch (e) {
renderError(e);
}
The alternative is returning a deterministic result tuple, a pattern borrowed from Go's explicit error handling architecture. The function always returns. The consumer always receives both the error and the data. The type system can enforce that the error is checked before the data is accessed.
// Modern — errors are return values, not surprises
async function getUser(id) {
try {
const user = await db.users.findById(id);
if (!user) return [new Error('User not found'), null];
return [null, user];
} catch (e) {
return [e, null];
}
}
// Calling code — the contract is explicit
const [error, user] = await getUser(id);
if (error) return renderError(error);
render(user);
The consuming code now cannot access user without first acknowledging error. The control flow is visible in the function's return type. There is no invisible throw waiting to crash the rendering pipeline. And the calling code is cleaner: no try...catch wrapper, no indentation cost.
For teams that want stronger typing guarantees, a Result type formalizes this pattern:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
async function getUser(id: string): Promise<Result<User>> {
const user = await db.users.findById(id);
if (!user) return { ok: false, error: new Error('User not found') };
return { ok: true, value: user };
}
const result = await getUser(id);
if (!result.ok) return renderError(result.error);
render(result.value); // TypeScript knows value exists here
The Result type makes the contract explicit at the type level. A consumer cannot access result.value without first narrowing through result.ok the compiler enforces the error acknowledgement.
Never mutate incoming parameters
Mutating an incoming parameter object is one of the quietest sources of production bugs in JavaScript. It feels harmless; the object is right there, and you need to change a property, so you change it. But the caller still holds a reference to that same object, and now their data has changed without them knowing.
// Dangerous — mutates the caller's object
function applyDiscount(order, rate) {
order.total = order.total * (1 - rate); // caller's order is now modified
return order;
}
// Safe — returns a new object, caller's data is untouched
function applyDiscount(order, rate) {
return { ...order, total: order.total * (1 - rate) };
}
Beyond correctness, mutation destroys JavaScript engine optimizations. Modern engines track object shapes, the set of properties an object has, to optimize property access. When you reassign properties on an incoming parameter, you can cause the engine to de-optimize that object's shape, which degrades performance in hot paths silently and permanently until the page is refreshed.
In concurrent environments, React's concurrent mode, service workers, and edge runtimes processing multiple requests and parameter mutations become a race condition. Two execution contexts modifying the same object simultaneously produces state that neither intended and that neither can predict.
The rule is absolute: treat every incoming parameter as read-only. Return a new object with your changes applied. This is not a performance concern; the cost of object spread is negligible compared to the cost of a mutation bug in production.
Actionable advice for technical leads
Mandate RORO at the linter level
Add a custom ESLint rule or use eslint-plugin-functional to flag functions with more than two positional parameters. The point is not to catch every violation automatically — it is to make the pattern the path of least resistance. When a lint warning fires, the engineer reaches for destructuring rather than adding a third positional argument.
Enforce the result tuple at the type level
Define a shared Result<T> type in your project's core utilities and require it for all async functions that can produce expected failures. Put it in a shared types/result.ts and lint for Promise<T> return types on async functions in your data layer; those are the functions most likely to swallow errors.
Make parameter mutation a blocking PR comment
Add it to your team's code review checklist as a blocking item, not a suggestion. "Does this function mutate its parameters?" is a five-second scan that prevents a class of bugs that take hours to diagnose. Once the team internalizes the rule, it becomes automatic.
What to audit this week
# Find functions with three or more positional parameters
grep -rn "function.*(.*, .*, .*," src/ --include="*.ts" --include="*.tsx"
# Find throw statements in data-layer files — candidates for result tuples
grep -rn "throw new Error" src/lib src/utils src/services
# Find direct parameter mutation patterns
grep -rn "params\.\|options\.\|config\.\|payload\." src/ | grep "= " | grep -v "const\|let\|var"
Start with your service layer and utility files — those are the functions called most frequently and the ones where a bad contract compounds across the most call sites.
Summary
A function's signature is its API contract with the rest of your codebase. If that contract requires the next engineer to guess the correct sequence of variables or wrap the execution in a defensive try...catch block just to survive runtime, the design has failed before a single line of business logic runs.
True seniority is writing functions that are impossible to use incorrectly, where the call site is self-documenting, where failure states are explicit return values rather than invisible exceptions, and where the function's behavior is deterministic regardless of what the caller omits or forgets.
Look at your team's core utility files today. Are they self-documenting, deterministic contracts or just memory tests for the engineers who have to use them?

Top comments (0)