Options objects over long parameter lists
The TypeScript convention for a function's parameter list, when to switch to an options object, how defaults fire, and how rest and spread cross the call boundary.
A reviewer opens a PR and sees this call: createUser('alex', 'a@x.com', true, false, true). Which boolean is admin? Which is sendWelcomeEmail? Which is acceptedTerms? To be sure, the reviewer has to open the function definition and count argument positions. If a teammate reorders the booleans tomorrow, the result is a silent bug. If the next person adds a marketingOptIn parameter, the change breaks every existing caller.
The fix is a single rule: at most two positional parameters, then switch to a single options object.
Two parameters max, then switch to an options object
Section titled “Two parameters max, then switch to an options object”Both tabs below define the same createUser with the same five inputs. The difference shows up at the call site.
const createUser = ( name: string, email: string, admin: boolean, sendWelcomeEmail: boolean, acceptedTerms: boolean,) => { // ...};
createUser('alex', 'a@x.com', true, false, true);Five positional parameters , three of them booleans. The call site doesn’t say which true is which. Swap any two booleans and you get a silent bug: the types still match and the function still runs, but the wrong user gets admin rights or skips the welcome email. Adding a marketingOptIn parameter next month forces a sixth argument into the right slot in every existing call.
const createUser = (options: { name: string; email: string; admin?: boolean; sendWelcomeEmail?: boolean; acceptedTerms: boolean;}) => { // ...};
createUser({ name: 'alex', email: 'a@x.com', admin: true, sendWelcomeEmail: false, acceptedTerms: true,});Every argument names itself at the call site. Object fields are unordered, so there’s nothing to swap. Adding a marketingOptIn field next month breaks no existing caller: old calls keep working, new calls opt in. The inline { … } after options: is an object type literal ; for now you only need to read its shape, where ? marks a field optional.
The rule: past two positional parameters, switch to an options object. Two is the threshold, not “two or three.” By the third argument the reader is counting positions instead of reading meanings.
One exception, worth naming because the language itself relies on it: keep positional arguments when the positions are semantic, so reordering them changes what the function does. A comparator (a, b) => number and a reducer (acc, x) => acc have fixed slots because Array.sort and Array.reduce invoke them that way; a range helper (min, max) => … puts the lower bound first by convention. Application code rarely qualifies. In a createUser, a sendEmail, or a chargeCard, the positions carry no meaning, so those functions get an options object.
A wide signature also carries a second signal. At four or five parameters, the problem is often that the function does too many things, and the fix is to split it rather than box the arguments. createUserAndSendWelcomeEmail is two operations pretending to be one; the cleaner shape is createUser returning the user, then sendWelcomeEmail(user) as a separate call.
The options object isn’t just a style preference; it’s the call shape the rest of the course leans on, from Server Actions to React props. The form is decided here, once.
Parameter defaults fire only on undefined
Section titled “Parameter defaults fire only on undefined”A parameter default fires only when the argument is undefined. Not null, not 0, not '', not false. Every other value flows through untouched, even the falsy ones.
This trips up developers coming from older JavaScript, where the common default was ||: const pageSize = arg || 20. That form fires on every falsy value (0, '', false, null, and undefined), so a deliberate 0 from the caller gets silently replaced with 20. Parameter defaults are narrower on purpose: only undefined triggers them. This nullish-versus-falsy distinction returns with the ?? and || operators later in this chapter; a parameter default is the signature-level form of the same idea.
const greet = (name: string = 'friend') => `Hello, ${name}!`;
greet(); // 'Hello, friend!'greet(undefined); // 'Hello, friend!'// @ts-expect-error — demonstrating the runtime rule: null is not undefined, so the default does not fire.greet(null); // 'Hello, null!'greet(''); // 'Hello, !'The first two calls trigger the default, because a missing argument and an explicit undefined are the same thing to the parameter binding. The third passes null, which is not undefined, so the default stays put and the template interpolates 'null'. The fourth passes an empty string, which is falsy but defined, so the default does not fire.
Predict the output of the following snippet, then check.
Predict what this program prints, then press Check.
const list = (page: number = 1, size: number = 20) => console.log(`page=${page}, size=${size}`);
list();list(undefined, undefined);list(1, 0);list(1, 10);undefined. The first two calls leave both parameters undefined, so both defaults fire — page=1, size=20. The third call passes 0 for size, a real value the caller asked for, so the default does not fire and size=0 flows through. The fourth call passes both values explicitly.The options object removes parameter-ordering rules
Section titled “The options object removes parameter-ordering rules”TypeScript requires that a required parameter never follow an optional one, so (options?: Options, url: string) is a compile error: a positional call site has no way to skip the optional parameter while still supplying the required one. A single options object sidesteps this:
const fetchPage = (options: { url: string; headers?: Record<string, string>; timeoutMs?: number;}) => { // ...};Object fields are unordered, and ? marks one field as omittable without touching the others. url stays required while headers and timeoutMs are optional, and the call site reads cleanly with any subset of them, or none.
Rest parameters and spread arguments
Section titled “Rest parameters and spread arguments”Rest and spread are both written as three dots, but they do opposite things depending on which side of the function boundary they sit on. The mental model fits in one sentence: rest gathers positional arguments into an array on the way in; spread unpacks an array into positional arguments on the way out.
On the signature side, a rest parameter collects every trailing positional argument into one array:
const tag = (label: string, ...ids: string[]) => { for (const id of ids) { console.log(`${label}: ${id}`); }};
tag('user', 'u_1', 'u_2', 'u_3');...ids gathers the three trailing strings into an array typed string[]. A rest parameter must be the last parameter, since anything after it would have no positions left to bind, and a signature can have only one. Any number of regular parameters can come before it.
On the call-site side, spread reverses the operation, unpacking an array into individual arguments:
const ids = ['u_1', 'u_2', 'u_3'];tag('user', ...ids);The array expands in place, so tag receives the same four arguments as the earlier call. Same three dots, opposite direction.
The common use for both in web app code is the wrapper pattern: a function that adds behavior around another function, such as logging, timing, or error translation, and forwards every argument straight through.
const logged = (...args: Parameters<typeof baseFn>) => { console.log('calling baseFn with', args); return baseFn(...args);};Parameters<typeof baseFn> is a TypeScript utility type that extracts baseFn’s argument tuple, so logged keeps matching baseFn’s signature however it changes; a later TypeScript chapter covers it in full. The runtime shape is just rest on the way in and spread on the way out.
Older JavaScript used a built-in arguments object inside function declarations to collect call-site arguments; rest parameters replace it, and you’ll only meet it in old code.
Defaults inside the options-object pattern
Section titled “Defaults inside the options-object pattern”The options object and parameter defaults combine into one shape, the one every paginated list function in this course uses.
const listInvoices = ({ pageSize = 20, sort = 'asc',}: { pageSize?: number; sort?: 'asc' | 'desc';} = {}) => { // ...};
listInvoices();listInvoices({ pageSize: 50 });listInvoices({ pageSize: 50, sort: 'desc' });Two pieces do the work. The field-level defaults (pageSize = 20 and sort = 'asc' inside the destructure) fill in any field the caller leaves undefined, including a field omitted entirely, exactly as regular parameter defaults do. The trailing = {} on the parameter itself makes the function callable with no argument: without it, listInvoices() would try to destructure undefined and crash. The = {} supplies an empty object to destructure instead, and every field-level default then fires.
The { pageSize = 20, sort = 'asc' } = ... half of the signature is parameter destructuring, covered in full in the destructuring lesson later in this chapter. Here you only need to recognize the canonical shape.
Exercises: refactor to an options object
Section titled “Exercises: refactor to an options object”Two exercises turn the rule into practice: a PR review where you flag the signatures that break the two-parameter limit, then a refactor where you rewrite one.
Review the PR below. Both functions are over the limit, and both have call sites in the same file. Flag the offending signatures and explain the refactor.
Review this PR. The team's rule is two positional parameters max. Flag every function signature that breaks it and explain what the refactor should look like. Click any line to leave a review comment, then press Submit review.
export const createUser = ( name: string, email: string, admin: boolean, sendWelcomeEmail: boolean, acceptedTerms: boolean,) => { // ...};
export const listInvoices = ( orgId: string, pageSize: number, sort: 'asc' | 'desc',) => { // ...};
createUser('alex', 'a@x.com', true, false, true);listInvoices('org_1', 20, 'asc');Five positional parameters, three of them booleans. At the call site, createUser('alex', 'a@x.com', true, false, true), no one can tell which true is admin and which is acceptedTerms; swap any two and it’s a silent bug, and adding a marketingOptIn parameter breaks every caller. Refactor to a single options object: (options: { name: string; email: string; admin?: boolean; sendWelcomeEmail?: boolean; acceptedTerms: boolean }). Now the call reads createUser({ name: 'alex', email: 'a@x.com', admin: true, sendWelcomeEmail: false, acceptedTerms: true }) — self-documenting, reorderable, backward-compatible.
Three positional parameters is past the limit — the rule is two, not “two or three.” Three is where reviewers wave it through, yet listInvoices('org_1', 20, 'asc') already forces you to count positions to find pageSize and sort. Refactor to (options: { orgId: string; pageSize?: number; sort?: 'asc' | 'desc' }).
The two-positional-parameter rule is structural, not stylistic: past two, the call site stops reading clearly and adding fields breaks callers. The fix is the same everywhere — one options object with field-level optionals.
Now the hands-on half. The function below has four positional parameters, one already defaulted and one already optional. Refactor it to a single options object: street and city are required, country defaults to 'US', and zip has no default and drops out of the output when missing. The last test checks the undefined-only firing rule from earlier, so watch for it.
Refactor formatAddress to take a single options object. Required fields: street, city. Optional fields: country (defaults to 'US'), zip (no default — omit from the output when missing). The function returns the parts joined by ', '.
The fourth test is deliberate: a caller who explicitly passes undefined for country still gets 'US'. Writing the default as country = 'US' inside the destructure satisfies it — the destructure pulls the field out as undefined, so the default fires. It’s the same firing rule as a plain parameter default, one level deeper.
External resources
Section titled “External resources”The canonical reference for parameter defaults, including the `undefined`-only firing rule and the interaction with destructured parameters.
Rest parameter syntax, the must-be-last restriction, and the contrast with the legacy `arguments` object.
Official TS reference for function type expressions, optional and default parameters, and the required-before-optional ordering rule.
The lint rule that mechanically enforces the two-parameter heuristic. Drop it in `eslint.config.js` with `{ max: 2 }` and the rule is checked for you on every signature.