Real test helpers rarely need every argument every time. A waitForElement helper has a sensible default timeout. A createUser helper accepts a role but assumes "tester" if you don't pass one. A logSteps helper takes any number of step descriptions. TypeScript has three tools for these patterns — optional, default, and rest parameters — each with rules about ordering and behaviour. This lesson covers all three.
Optional parameters — ?
A ? after a parameter name marks it as optional. The caller may omit it; inside the function the parameter has type T | undefined.
interface User {
name: string;
email: string;
role: string;
}
function createUser(name: string, email: string, role?: string): User {
return { name, email, role: role ?? "tester" };
}
createUser("Alice", "alice@test.com"); // ✅ role defaults to "tester"
createUser("Bob", "bob@test.com", "admin"); // ✅ role is "admin"
createUser("Carol", "carol@test.com", undefined); // ✅ explicit undefinedThe ?? operator is nullish coalescing — "use the left side if it's not null or undefined, otherwise the right side." It's the cleanest way to apply a fallback without the truthiness pitfalls of ||.
Rule: optional parameters must come after required ones. The compiler refuses the reverse:
function bad(role?: string, name: string) { return { name, role }; }
// ❌ A required parameter cannot follow an optional parameter.If you need flexibility around the order, group the flexible bits into an options object — see the runner example below.
Default parameters
Provide a value with = and the parameter takes that value when the caller omits it. Default-valued parameters are automatically optional at the call site.
function waitForElement(selector: string, timeout: number = 5000): void {
console.log(`Waiting up to ${timeout}ms for ${selector}`);
}
waitForElement(".login-btn"); // timeout = 5000
waitForElement(".login-btn", 10000); // timeout = 10000
waitForElement(".login-btn", undefined); // timeout = 5000 (default kicks in for undefined)Inside the function, timeout has type number — not number | undefined. The default removes the "could be missing" branch entirely. That's the practical reason to prefer default over optional whenever you have a sensible fallback: cleaner type, no narrowing, simpler code.
// Optional — body has to handle undefined
function a(timeout?: number): void {
// timeout is number | undefined here
}
// Default — body never sees undefined
function b(timeout: number = 5000): void {
// timeout is just number here
}Reach for ? only when the absence of the parameter has its own meaning beyond "use the default."
Rest parameters — ...args
A rest parameter collects any number of trailing arguments into an array. The annotation is the array type:
function logSteps(...steps: string[]): void {
steps.forEach((step, i) => console.log(`Step ${i + 1}: ${step}`));
}
logSteps("Navigate to login", "Enter username", "Click submit");
// Step 1: Navigate to login
// Step 2: Enter username
// Step 3: Click submitThe ... syntax is the JavaScript spread/rest you already know — TypeScript adds the : string[] annotation. Rules:
- A function may have at most one rest parameter.
- It must come last in the parameter list.
- Inside the function, the rest parameter is a real array —
length,forEach,mapall work.
Rest parameters are how variadic test runners, log builders, and assertion chains accept "as many things as you want to pass."
An options-object pattern for QA helpers
Once a function has more than two or three flexible inputs, individual ? parameters get unwieldy — every call site has to remember the order. The everyday solution: take a single options object with each field defaulted via destructuring.
function runTest(
testName: string,
options: {
browser?: "chromium" | "firefox" | "webkit";
headless?: boolean;
retries?: number;
} = {}
): void {
const { browser = "chromium", headless = true, retries = 0 } = options;
console.log(`Running ${testName} on ${browser}, headless: ${headless}, retries: ${retries}`);
}
runTest("Login");
runTest("Login", { browser: "firefox" });
runTest("Login", { browser: "webkit", headless: false, retries: 2 });The whole options parameter has a default of {}, so callers can omit it entirely. Inside, destructuring with = provides per-field defaults. This is the pattern Cypress, Playwright, and most modern test libraries use for configuration — once you recognise it, you'll see it everywhere.
Three flavours of "may be missing"
Three ways to leave a parameter out — and when to use each
(Each bar is the same height — this is a labelled cheat-sheet of the four parameter modifiers, plus the options-object pattern that combines them.)
A QA-shaped helper using all of them
A configurable test runner that exercises every tool from this lesson:
function runSuite(
suiteName: string, // required
baseUrl: string = "https://staging.app.com", // default
options: {
browser?: "chromium" | "firefox" | "webkit";
headless?: boolean;
retries?: number;
} = {}, // default = {}
...tags: string[] // rest
): void {
const { browser = "chromium", headless = true, retries = 0 } = options;
console.log(`Suite: ${suiteName}`);
console.log(`URL: ${baseUrl}`);
console.log(`Run: ${browser} (headless=${headless}, retries=${retries})`);
if (tags.length > 0) console.log(`Tags: ${tags.join(", ")}`);
}
runSuite("Smoke");
runSuite("Smoke", "https://qa.app.com");
runSuite("Smoke", "https://qa.app.com", { browser: "firefox", retries: 2 });
runSuite("Smoke", "https://qa.app.com", {}, "auth", "critical-path");The signature is the documentation. A reader can tell from the annotation alone what's required, what's default, what the options shape looks like, and that any trailing strings are tags.
⚠️ Common mistakes
- Using optional when default would do.
function wait(timeout?: number) { const t = timeout ?? 5000; ... }works but spreads the default across the body.function wait(timeout: number = 5000)keeps it at the signature, where the contract belongs. Prefer default whenever a sensible fallback exists. - Putting an optional parameter before a required one.
function f(x?: number, y: string)is a compile error. The fix is either to reorder (function f(y: string, x?: number)) or to switch to an options-object pattern when ordering is awkward. - Reaching for rest parameters when an array would be clearer.
logSteps("a", "b", "c")is fine.logSteps(...steps)from a caller that already has an array becomes noisy. If the caller almost always passes an existing array, declare the parameter assteps: string[]and let them pass it directly.
🎯 Practice task
Build a configurable test helper. 20-30 minutes.
- In your
ts-for-qa/srcfolder, createrunner-config.ts. - Define
interface User { name: string; email: string; role: "admin" | "tester" | "viewer" }. - Write
function createUser(name: string, email: string, role: User["role"] = "tester"): Userusing a default parameter (no optional?). - Write
function logSteps(prefix: string, ...steps: string[]): voidthat prints each step prefixed withprefix:. - Write
function runSuite(name: string, options: { browser?: "chromium" | "firefox" | "webkit"; headless?: boolean; retries?: number } = {}): voidusing the options-object pattern with destructuring defaults. - Call each function with and without the optional inputs. Run with
npx ts-node src/runner-config.ts. - Trigger the ordering rules. Try
function bad(role?: string, name: string)— read the error and revert. - Stretch: convert one of the optional parameters to a default and observe how the body simplifies — no
??fallback inside, the type is no longer... | undefined. Note in a comment which trade-off (optional vs default) you'd pick for that helper in a real test suite, and why.
The next lesson covers function overloads — describing one function whose return type depends on the argument type, the way Cypress and Playwright type their multi-shape APIs.