Skip to content
Chapter 2Lesson 2

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.

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.

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);

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 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.

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.

src/users/users.ts
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');

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.