Signatures that stay readable past two parameters
The TypeScript convention for shaping a function's parameter list, when to switch to an options object, how parameter defaults fire, and how rest and spread move arguments across 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? The reviewer has to jump to the function definition to find out, and even then they have to count argument positions to be sure the call site matches. If a teammate reorders the booleans tomorrow, the result is a silent bug. If the next person needs a marketingOptIn parameter, adding it breaks every caller in the codebase.
The fix is a single rule: at most two positional parameters, then switch to a single options object. The rest of this lesson shows why the rule works and how to apply it.
The previous lesson, “Arrow by default, declaration on demand”, picked the form of every function the course writes: arrow expressions bound to const. This lesson picks the shape of the parameter list inside that form. You’ll see why two is the threshold and why an options object removes the readability problem. You’ll learn the firing rule for parameter defaults that trips up developers returning to TypeScript: a default fires on undefined only, never on null or 0. You’ll meet the TypeScript ordering rule that the options object also resolves, the pairing of rest at the signature with spread at the call site, and the field-level default pattern that every paginated function in the course will use. The lesson closes with a refactor exercise where you take a function with five positional arguments and convert it.
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 function: same five inputs, same intent. 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. Swapping any two booleans is 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 breaks every existing caller in the repo, because every call has to grow a sixth argument in the right slot.
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. Order doesn’t matter here: object fields are unordered, so there’s nothing to swap. Adding a marketingOptIn field next month doesn’t break any existing caller. Old calls keep working, and new calls opt in. The inline { … } after options: is an object type literal . A later chapter on object types and structural typing covers the type-system side in depth; for now you only need to read the shape. The ? after a field name marks that field as optional.
To state the rule plainly: past two positional parameters, switch to an options object. Two is the threshold, not “two or three.” A three-parameter signature already starts to strain, because by the third argument the reader is counting positions instead of reading meanings.
There is one exception, worth naming because the language itself relies on it. Some functions take positional arguments because the positions are semantic: reordering them changes what the function does. A comparator (a, b) => number has a on the left and b on the right, and Array.sort calls it that way, so you don’t get to switch the slots. A reducer (acc, x) => acc has the accumulator first and the current item second, because that’s the contract Array.reduce invokes. A range helper (min, max) => … has the lower bound first by convention. So keep positional parameters past two only when the function’s identity requires a fixed argument order, as with mathematical conventions, callback contracts the runtime invokes, and binary operators. Most application code doesn’t qualify. In a createUser, a sendEmail, or a chargeCard, the positions carry no meaning of their own, so those functions get an options object.
A signature this wide also carries a second signal. When a function reaches four or five parameters, an experienced engineer reads it as “this function does too many things” before “this function needs an options object.” Sometimes the right refactor isn’t to box the arguments into an object but to split the function in two. 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 is this lesson’s main pattern. Splitting the function is the deeper heuristic, and you’ll reach for it more often as your sense for cohesion sharpens.
The options-object shape isn’t just a style preference; it’s the call shape the rest of the course uses. Server Actions (in the chapter on the Server Action surface) take a single options-shaped argument. React component props (in the React chapters) are an options object written with JSX. Drizzle’s query builder (in the Postgres unit) accepts options at every chain step. The form is decided here, once.
Parameter defaults fire only on undefined
Section titled “Parameter defaults fire only on undefined”Before you write your first signature with a default, there is one rule to learn.
A parameter default fires only when the argument is undefined. Not null, not 0, not '', not false. Every value other than undefined flows through untouched, even the falsy ones.
This rule trips up developers returning to TypeScript from older JavaScript habits. Code from before 2015 commonly defaulted with ||: const pageSize = arg || 20. That form fires on every falsy value (0, '', false, null, and undefined), which means a deliberate 0 passed by the caller gets silently replaced with the default 20. Parameter defaults are narrower and more predictable on purpose: only undefined triggers them. The same nullish-versus-falsy split shows up in the ?? versus || operator pair, which gets its own lesson later in this chapter. A parameter default is the signature-level form of that same idea.
Here are the four cases worth knowing, in one block.
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 no argument and explicit undefined are the same thing to the parameter binding. The third passes null, which is not undefined, so the default does not fire and the template interpolates the string 'null'. The fourth passes an empty string, which is falsy but defined; the default does not fire.
Predict the output of the following snippet, then check. All four calls follow the one rule above.
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; 0 is falsy but it is 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 same nullish-vs-falsy distinction the ?? operator formalizes — covered in the lesson on ?? and || later in this chapter.Required parameters before optional, and how the options object dissolves the rule
Section titled “Required parameters before optional, and how the options object dissolves the rule”TypeScript enforces an ordering rule on the parameter list: a required parameter cannot follow an optional one. (options?: Options, url: string) is a compile error. The reason is that positional call sites have no way to skip the optional parameter and still supply the required one.
The workaround, in 2026 practice, is rarely “reorder so required comes first.” It’s the options object you already know:
const fetchPage = (options: { url: string; headers?: Record<string, string>; timeoutMs?: number;}) => { // ...};With a single options-object parameter the required-before-optional rule disappears entirely. Fields on an object are unordered, and the ? modifier marks just that one field as omittable without affecting the others. url stays required; headers and timeoutMs are optional. The call site reads cleanly with any subset of the optional fields, or none of them.
TypeScript does allow one carve-out: an optional positional parameter with a default value (url: string = 'http://localhost') can come before a required one. The catch is that the call site then has to pass undefined explicitly to skip it, which is the same readability problem positional booleans have. The course doesn’t reach for that form, because the options object reads better in every case the carve-out would cover.
Rest at the signature, spread at the call site
Section titled “Rest at the signature, spread at the call site”One more mechanic belongs in this lesson. Rest and spread are two pieces of syntax that look identical, both written as three dots, but they do opposite things depending on which side of the function boundary they’re on. The mental model fits in one sentence: rest gathers positional arguments into an array as they come in; spread unpacks an array back into positional arguments as they go out.
On the signature side, the rest parameter collects every trailing positional argument into a single 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 collects the three trailing strings into an array typed string[]. The rest parameter has one structural restriction: it must be the last parameter in the signature, because anything after it would have no positions left to bind. You can have any number of regular parameters before it, but only one rest parameter.
On the call-site side, spread reverses the operation, unpacking an array back into individual positional arguments:
const ids = ['u_1', 'u_2', 'u_3'];tag('user', ...ids);The spread expands the array in place, so tag receives the same four arguments as the earlier call. Same three dots, opposite direction.
The most common use for both in 2026 SaaS 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 the argument tuple of baseFn and types args as that exact shape. That keeps logged matching baseFn’s signature no matter how baseFn changes. Typed wrappers and the generics behind them get full treatment in a later TypeScript chapter; the runtime shape is just rest on the way in and spread on the way back out.
One legacy note before moving on. Older JavaScript used a built-in arguments object inside function declarations to collect call-site arguments. It is array-like but not actually an array, it doesn’t exist inside arrow functions at all, and rest parameters replace it completely. The course never writes arguments; it’s named once here so you recognize it when you come across 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. Every paginated list function in the course will use it, so it earns its own short section.
const listInvoices = ({ pageSize = 20, sort = 'asc',}: { pageSize?: number; sort?: 'asc' | 'desc';} = {}) => { // ...};
listInvoices();listInvoices({ pageSize: 50 });listInvoices({ pageSize: 50, sort: 'desc' });Two pieces work together here. The field-level defaults (pageSize = 20 and sort = 'asc' inside the destructure) supply per-field fallbacks the same way regular parameter defaults do: they fire when the field is undefined, which includes the case where the caller omits the field entirely. The trailing = {} on the parameter itself is the piece that’s easy to overlook. It makes the function callable with no argument at all. Without it, listInvoices() would crash trying to destructure undefined, because there are no fields to pull out of undefined. The = {} says: if there’s no argument, use an empty object as the source for the destructure. Every field-level default then gets its chance to fire.
The { pageSize = 20, sort = 'asc' } = ... half of that signature is parameter destructuring, a mechanic that belongs to the lesson on destructuring later in this chapter. Here you only need to read the canonical shape; that lesson derives why it works and covers the four extensions (rename, nested, rest at destructure, default at destructure) in depth.
Refactor: from positional soup to options object
Section titled “Refactor: from positional soup to options object”Two exercises close the lesson and turn the rule into a habit. First comes a PR review where you flag the signatures that break the two-parameter rule, then a hands-on refactor where you rewrite one.
Review the PR below. It has two functions, both over the limit, both with call sites in the same file. Click the offending lines and leave inline review comments explaining what the refactor should look like.
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');The createUser signature breaks the two-positional-parameter rule hard — five positional arguments, three of them booleans. The call site (createUser('alex', 'a@x.com', true, false, true)) is unreadable; no reviewer can tell which true is admin and which is acceptedTerms. Swapping any two booleans is a silent bug. Adding a marketingOptIn parameter next month breaks every caller. Refactor to a single options object: (options: { name: string; email: string; admin?: boolean; sendWelcomeEmail?: boolean; acceptedTerms: boolean }). The call site then reads createUser({ name: 'alex', email: 'a@x.com', admin: true, sendWelcomeEmail: false, acceptedTerms: true }) — self-documenting, reorderable, backward-compatible.
listInvoices has three positional parameters. That’s past the rule. The rule is two, not “two or three.” Three is the case where reviewers most often hand-wave — but the call site listInvoices('org_1', 20, 'asc') already requires the reader to count positions to see which is the pageSize and which is the sort. Refactor to (options: { orgId: string; pageSize?: number; sort?: 'asc' | 'desc' }). Bonus: this is the exact shape the field-level default pattern from the previous section was waiting to be poured into.
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 uniform across the codebase — single options object with field-level optionals. This is the shape every Server Action, every React component prop, and every Drizzle query helper in the rest of the course will take. The earlier you install the reflex, the less rework the later chapters demand.
Now for the hands-on half. The function below has four positional parameters, past the limit, with one already defaulted and one already optional. Refactor it to take a single options object. The required fields are street and city; country defaults to 'US'; zip has no default and is omitted from the output when missing. The tests check both the structural shape and the default-firing rule from earlier in the lesson, so pay attention to the last one.
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 the deliberate one. It checks the undefined-only firing rule in the refactored shape: a caller who explicitly passes undefined for country still gets the default. The field-level default written as country = 'US' inside the destructure is the form that satisfies it. The destructure pulls the field out as undefined, the default fires, and the function reads 'US'. It’s the same firing rule as a plain parameter default, applied one level deeper.
Carry these rules forward: at most two positional parameters, an options object past that, defaults that fire on undefined only, rest that receives an array, and spread that sends one. When you write your first Server Action, it will take a single options object. When you write your first React component, its props will be an options object written with JSX. When you write your first Drizzle list query, it will use the field-level default form. All three follow the shape this lesson settled, and the rest of the course uses it without revisiting.
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.