Parameter Object
Three parameters, then a struct.
Too many parameters is a missing type. When the same group of values keeps travelling together, bundle them. The call sites compress; the contract clarifies; the linter stops complaining at four.
Opinion
I have lost count of the times I have read a function call with five positional arguments and had to count the commas backwards to work out which one is the timeout and which one is the retry count. The fix has been in the Refactoring catalogue since 1999: Introduce Parameter Object.1Refactoring, 2nd ed. (Addison-Wesley, 2018). Introduce Parameter Object — bundle a recurring group of arguments into a class. The Data Clump bliki entry names the underlying smell. Two engineers see the same fields travelling together, the third caller arrives, the bundle gets a name and a type, and the call sites stop reading like SQL with the column names removed.
The cap is editorial. Robert C. Martin says zero is ideal, three is the polyadic ceiling, and anything past that needs “very special justification”.2Clean Code (Prentice Hall, 2008), ch. 3 “Functions”. “The ideal number of arguments for a function is zero (niladic) … More than three (polyadic) requires very special justification.” The Prickles repo sets max-params: 3; ESLint and typescript-eslint both ship the rule out of the box. The discipline is to stop negotiating: at four, the function fails the lint and the caller writes the struct. A struct with three named fields reads better than four positional anything.
The pairing is what makes the rule useful. F2 Intention-Revealing Names picks the struct's name; F6 Encapsulation decides whether the new type is a value or an entity; T1 Domain-Driven Types brands the fields so the function signature carries the constraint. The lint fires; the rest of the canon catches the work.
Copy a note and link
Grab this short comment and drop it into a PR comment or an LLM chat to prompt the right change.
Too many parameters is a missing type. When the same group of fields keeps travelling together, bundle them into a named struct. Three positional parameters is the cap; the fourth is the lint failure that asks for the struct. /tenet/parameter-object/S3
AI eyes only
Rule: three parameters, then a struct.
Reject: function signatures with four or more positional parameters. Reject: adding “one more” optional argument to an existing four-arg helper.
Generate: when a function needs a fourth argument, group related arguments into a typed struct (or destructured object) named for the role.
Diagnostic: count parameters. Four or more: group into a struct. If the struct has no obvious name, the grouping is wrong; rethink the responsibilities.
Why?
- Call sites stop reading like SQL with the column names removed. A struct with three named fields is self-explanatory at the next reviewer's first read; four positional arguments are not.
- The struct's name is the missing type. F2 Intention-Revealing Names picks the name; the linter forces the conversation that produces it.
- Eliminates argument-order bugs. A function signature
sendEmail(to, from, subject, body, replyTo)ships the bug wheretoandfromare swapped. A struct with named fields cannot. - Stable signatures across versions. Add a new field to the struct and existing callers compile; add a new positional argument and every call site has to be updated.
- Coding agents stop generating five-argument helpers. The cap is the spec the agent inherits; the linter enforces what the model would otherwise default to.
- Hands the encapsulation conversation to F6 Encapsulation. The struct decides whether the fields are a value object or an entity; the cap is what forces the decision in the first place.
- Cuts review time. Reviewers stop arguing about whether the parameter list is too long; the linter decides for them. The conversation moves to whether the struct is well-named.
Origins
Martin Fowler catalogued Introduce Parameter Object in the first edition of Refactoring in 1999 and reprinted it in the second edition in 2018.1Refactoring, 2nd ed. (Addison-Wesley, 2018). Introduce Parameter Object — bundle a recurring group of arguments into a class. The Data Clump bliki entry names the underlying smell. The refactor names the smell it answers — Data Clump — directly: a group of data items that appear together across multiple signatures, parameter lists or class fields. The catalogue entry is operational: identify the clump, create a class, replace the parameters with the new type, update the callers, run the tests.
Robert C. Martin's Clean Code chapter on functions sharpened the threshold. “The ideal number of arguments for a function is zero (niladic). Next comes one (monadic), followed closely by two (dyadic). Three arguments (triadic) should be avoided where possible. More than three (polyadic) requires very special justification.”2Clean Code (Prentice Hall, 2008), ch. 3 “Functions”. “The ideal number of arguments for a function is zero (niladic) … More than three (polyadic) requires very special justification.” Three is the soft ceiling; four is the lint failure; the editorial position is that “very special justification” should live in a code comment with an audit trail, not in the next pull request.
The ESLint max-params rule (and its @typescript-eslint/max-params cousin) make the cap operational.4ESLint `max-params` rule (also typescript-eslint's `max-params`). The canonical lint surface for the parameter cap. Default 3; the Prickles repo runs it as a warn. Default 3; configurable per project; the Prickles repo runs it as a warn. Python's Pylint family carries the same rule as PLR0913; PHPMD and PMD ship ExcessiveParameterList; every mainstream linter has the lever.
The principle the rule sits underneath is older still. Parnas's 1972 paper on information hiding argues that modules earn their boundary by encapsulating a design decision.5“On the Criteria To Be Used in Decomposing Systems into Modules”, CACM 15(12), 1972. The foundational paper on information hiding. A struct earns its boundary by encapsulating a design decision; positional arguments leave the decision in the caller's head. A struct with three named fields encapsulates the “these belong together” decision; four positional arguments leave the decision in the caller's head. Same instinct, half a century earlier.
Quotes
I see groups of parameters that naturally go together. Replace them with an object that carries the data. Common parameter groups are an indication that I am missing a concept.
The ideal number of arguments for a function is zero (niladic). Next comes one (monadic), followed closely by two (dyadic). Three arguments (triadic) should be avoided where possible. More than three (polyadic) requires very special justification.
Bunches of data that hang around together really ought to be made into their own object. The fields and parameters that belong together don't know they belong together until you give them a name.
The best modules provide powerful functionality through simple interfaces. A simple interface is one with few parameters; the parameters that survive are the ones that carry information the module cannot infer.
Evidence
Twenty external sources, ranked by author authority. The first five are the canon; expand to see the rest, including the qualifiers and the named opposers. Each links out to its primary source.
- 01The canonical refactor. Replace a recurring group of parameters with an object; update callers; run tests. The catalogue entry the rule operationalises.
- 02Data Clump (bliki)SupportsNames the smell. A group of data items that travel together across signatures wants a name. The bliki entry is the shortest single-page statement of the rule.
- 03Clean Code, ch. 3 “Functions”SupportsThree is the polyadic ceiling; more than three needs special justification. The most-quoted single-sentence formulation of the cap.
- 04“Modules Should Be Deep.” Narrow interfaces over wide ones; the parameter object is the function-level instance of the rule. Aligned with Fowler on substance, broader in scope.
- 05“The Wrong Abstraction”QualifiesThe asymmetry-of-cost argument at the signature level. A struct extracted too soon is the wrong abstraction; pair the cap with the rule of three so the bundle earns its name.
Sixteen sources, two stances. The supporters cluster on the rule itself: Fowler's Introduce Parameter Object and Data Clump entries, Martin's Clean Code chapter, Ousterhout. The qualifiers further down (led by Metz) carry the “wait until the bundle has earned its name” reading. There is no substantive opposition; the disagreement is about timing, not about the rule itself.
Examples
// Before: 5 positional parameters. max-params: 3 fires; the call site reads as commas in a row.function scheduleHedgehogVisit(name: string, ageBand: string, lat: number, lng: number, preferredFood: string): void { logVisit({ name, ageBand, lat, lng, preferredFood });}
// After: a HedgehogVisit struct names the bundle. The signature becomes one parameter.type BurrowLocation = { lat: number; lng: number };type HedgehogVisit = { name: string; ageBand: string; location: BurrowLocation; preferredFood: string;}function scheduleHedgehogVisit(visit: HedgehogVisit): void { logVisit(visit);}
Enforcement
Apply these rules in eslint.config.mjs. The full enforcement across every tenet lives on the implementation page.
| Rule | Tool | Catches |
|---|---|---|
| @typescript-eslint/max-params | typescript-eslint | function and method signatures with more than 3 parameters. Excludes overload declarations; counts the implementation signature. |
| max-params (core, disabled when TS rule is on) | ESLint core | the same cap without TypeScript awareness. Disable the core rule when `@typescript-eslint/max-params` is configured to avoid double-reporting. |
| object-destructuring as the parameter object | typescript-eslint | the right pattern when the struct is genuinely one-off. A destructured object literal counts as one parameter, satisfies the lint, and ships the same call-site benefit. |
eslint.config.mjsconfiguration snippet
import tseslint from 'typescript-eslint';
export default tseslint.config({
files: ['**/*.{ts,tsx}'],
rules: {
'max-params': 'off',
'@typescript-eslint/max-params': ['warn', { max: 3 }],
}
});AI rules
.cursor/rules/s3-parameter-object.mdc---
description: Prickles S3 — Parameter Object
globs: "**/*.{ts,tsx,js,jsx,py,java,php}"
alwaysApply: false
---
## Prickles S3 — Parameter Object
Cap function parameters at three. The fourth parameter is the missing struct.
When the same group of fields keeps appearing together (a Data Clump), bundle them into a named type. Update callers; run tests.
Use the language's named-argument feature (object destructuring, keyword arguments) for cases where the struct is genuinely a one-off.
Refuse to add a fourth positional parameter to satisfy a new requirement. Either the new field belongs in the struct that should already exist, or the function is doing too much and needs to split.Repo layout, CI, and ESLint wiring for these paths live on /implementation — not repeated on every tenet.
Counter-argument
The strongest steelman is Sandi Metz's, restated for parameter lists.3“The Wrong Abstraction” (sandimetz.com, 2016). The asymmetry-of-cost argument applies to parameter objects too: a struct extracted from incomplete information is the wrong abstraction at the signature level. Metz's point is that a parameter object extracted from incomplete information is the wrong abstraction at the signature level. Two callers passing the same three fields together is not yet a Data Clump; it might be a coincidence of the API surface, and bundling the fields produces a struct that the third caller has to deconstruct because its requirements have moved on. The rule of thumb “three parameters, then a struct” is right; the rule of dogma “always wrap parameters” is wrong, and the linter knows the difference.
Counter-argument retort
Metz is right that a parameter object extracted too soon is the wrong abstraction at the signature level. The reply is that the rule of three applies to parameter objects too — bundle on the third caller, not the second.3“The Wrong Abstraction” (sandimetz.com, 2016). The asymmetry-of-cost argument applies to parameter objects too: a struct extracted from incomplete information is the wrong abstraction at the signature level. Two functions with three shared fields is a coincidence; three functions with the same three shared fields is a Data Clump and the struct earns its name. The cap fires at four parameters; the struct earns its existence at three callers. Two different triggers; one consistent rule.
The honest residue is the over-narrow object problem: a struct with three named fields can itself be the wrong abstraction when the third caller's field list is two of the three plus one new one. The answer is to split the struct, not to revert to positional parameters — the new struct still earns its name from the field combination it represents. The conversation is about the right struct, not whether to have one.
The genuine YAGNI residue is that some functions really do need four or five parameters and the struct is more ceremony than help. The constructor is the canonical example: a record with five required fields takes a five-argument constructor unless the language ships named arguments. The right answer there is to use the language's named-argument feature (Python keyword arguments, Kotlin named arguments, TypeScript object-destructuring) — which is the parameter object pattern with no extra type. The cap fires; the answer is the destructured object literal; the lint passes.
The pairing matters. The cap fires at four; F3 DRY says the second occurrence is the trigger; F2 Intention-Revealing Names picks the struct's name. The old “rule of three” counter is acknowledged and rejected: don't wait for three. With AI agents reading the change set before the human does, the cost of an early name is lower than the cost of duplication compounding silently.
Notes
- [1]Martin Fowler — Refactoring, 2nd ed. (Addison-Wesley, 2018). Introduce Parameter Object — bundle a recurring group of arguments into a class. The Data Clump bliki entry names the underlying smell.
- [2]Robert C. Martin — Clean Code (Prentice Hall, 2008), ch. 3 “Functions”. “The ideal number of arguments for a function is zero (niladic) … More than three (polyadic) requires very special justification.”
- [3]Sandi Metz — “The Wrong Abstraction” (sandimetz.com, 2016). The asymmetry-of-cost argument applies to parameter objects too: a struct extracted from incomplete information is the wrong abstraction at the signature level.