TypeScript Optional & Default Parameters Explained (With Examples)
TypeScript optional parameters — and their close sibling, default parameters — are the two tools that solve a problem every test automation engineer hits by their third month writing Playwright tests in TypeScript: you’ve got a login(username, password) helper that works fine, until a teammate needs to test the “remember me” checkbox, and someone else needs to pass a custom timeout because the staging environment is slow on Mondays. Do you add a third required parameter and break every existing call site? Do you overload the function three times? Do you build a giant options object nobody remembers the shape of six weeks later? This is the exact moment TypeScript optional parameters and default parameters earn their keep, and it’s also the exact moment I’ve watched engineers — good ones, with five and six years of experience — reach for the wrong tool because they never sat down and actually understood the mechanics, not just the syntax highlighting green squiggle that told them “this is fine.”
I’ve reviewed a lot of pull requests where a function signature ballooned to seven parameters because nobody wanted to touch the “risky” TypeScript optional parameter logic, and I’ve also seen the opposite failure mode — someone marks half a config object optional with ? and then the runtime blows up three services downstream because undefined silently slipped through a check that assumed a boolean would always be there. Both failures come from the same gap: treating optional and default parameters as interchangeable syntax sugar instead of understanding that they solve different problems and fail in different ways. This article closes that gap properly, with the kind of depth you need not just to write the code but to defend your design choices in a code review or an interview panel.
The Core Syntax: TypeScript Optional Parameters
An optional parameter in TypeScript is declared by appending a question mark to the parameter name, before the colon and the type annotation. Here’s the simplest possible example, framed the way you’d actually write it in a test automation context:
function waitForElement(selector: string, timeoutMs?: number): void {
// implementation
}
waitForElement('#submit-button');
waitForElement('#submit-button', 5000);
When you mark timeoutMs as optional, TypeScript does two things under the hood that are easy to take for granted. First, it widens the parameter’s effective type to include undefined — inside the function body, timeoutMs has the type number | undefined, not just number. Second, it updates the function’s call signature so the compiler no longer complains when you omit the argument entirely. Both of these matter more than they look like they do at first glance, and I’ll get to why in the “common mistakes” section, because the number | undefined part is where a shocking number of null-reference-style bugs are born in codebases that otherwise pride themselves on type safety.
The official TypeScript Handbook’s functions chapter covers the base mechanics well, but what it doesn’t spend much time on — because it’s a general-purpose language reference, not a testing guide — is how this plays out when you’re building a Page Object Model or an API test client where nearly every method has some “usually the default, sometimes not” parameter. That’s the gap this article is written to fill.
Why the Question Mark Position Matters
New TypeScript engineers, especially ones coming from C# or Java where TypeScript optional parameters look syntactically similar but behave with subtle differences, sometimes try to write timeoutMs: number? instead of timeoutMs?: number. This is a compile error in TypeScript — the question mark belongs to the parameter name, not the type. It’s a small thing, but I’ve seen it trip up experienced Selenium-with-C#-and-Java engineers making the jump to Playwright with TypeScript, because C#’s nullable value type syntax (int?) puts the question mark after the type. TypeScript’s TypeScript optional parameter marker and TypeScript’s nullable/optional type marker are positioned differently depending on context, and conflating them is one of the more common early friction points I see when reviewing code from engineers making that transition. I cover the broader C# to TypeScript mental model shift in more depth elsewhere on this blog, but the parameter-marking difference deserves calling out explicitly here because it’s exactly the kind of thing that looks like “just syntax” until it costs you twenty minutes staring at a red squiggle you don’t understand.
The Core Syntax: Default Parameters in TypeScript
A default parameter looks superficially similar but is a fundamentally different mechanism. Instead of a question mark, you assign a value directly in the parameter list:
function waitForElement(selector: string, timeoutMs: number = 5000): void {
// implementation
}
waitForElement('#submit-button'); // timeoutMs === 5000
waitForElement('#submit-button', 8000); // timeoutMs === 8000
Here’s the detail that actually matters and that most quick-reference articles gloss over: inside the function body, timeoutMs has type number, not number | undefined. TypeScript is smart enough to know that if you omit the argument, the default value kicks in immediately, so there’s no undefined branch to worry about downstream. This is not a small implementation detail — it’s the single biggest practical reason to prefer default parameters over optional parameters whenever you actually have a sensible default value. You get the ergonomic benefit of an omittable argument at the call site without paying the tax of null-checking it everywhere you use it inside the function.
Default parameters in TypeScript build directly on top of the ECMAScript 2015 default parameters feature — this isn’t a TypeScript-only invention, TypeScript just adds static type checking on top of a JavaScript runtime behavior. MDN’s reference on default parameters is the canonical source for the underlying runtime semantics, and it’s worth understanding that layer separately from TypeScript’s type-checking layer, because interview questions frequently probe whether you understand which behaviors come from JavaScript and which come from TypeScript specifically. I’ll come back to that distinction in the interview-prep section, because it’s one of the most commonly mis-answered questions I’ve seen candidates stumble on.
Default Values Are Evaluated at Call Time, Not Definition Time
This trips people up constantly, and I mean constantly — I’ve watched it cause a genuine production-adjacent bug in a CI pipeline configuration helper. Default parameter expressions are re-evaluated on every single call where the argument is omitted, not computed once when the function is defined. This matters enormously when your default value isn’t a primitive literal but something computed:
function generateTestRunId(prefix: string = 'run', timestamp: number = Date.now()): string {
return `${prefix}-${timestamp}`;
}
console.log(generateTestRunId()); // run-1735689600000 (some timestamp)
// wait a second...
console.log(generateTestRunId()); // run-1735689601000 (a DIFFERENT timestamp)
That’s exactly the behavior you want for a run-ID generator, and it’s also exactly the behavior that will bite you if you assumed default values are computed once and cached, the way a class field initializer might behave in some other languages. I’ve seen this assumption cause a subtle bug in a retry-helper utility where an engineer expected a default “attempt started at” timestamp to represent the moment the retry loop kicked off, but because the default was re-evaluated on every recursive call, every retry attempt got a fresh timestamp instead of inheriting the original one. The fix was trivial once diagnosed — compute the timestamp once outside the function and pass it explicitly — but the diagnosis took longer than it should have because the engineer’s mental model of “default parameter” was closer to “class field initializer” than “expression evaluated fresh at each call.”
TypeScript Optional Parameters vs. Default Parameters: The Decision That Actually Matters
Given that both features let you omit an argument at the call site, when do you actually reach for one over the other? This is the question I ask in interviews, and it’s the question that separates engineers who copy-pasted syntax from engineers who understand the underlying type system. Here’s my actual decision framework, refined over a decade of building test automation frameworks across BFSI, wealth management, healthcare, and payments codebases where the cost of a runtime undefined bug is not hypothetical:
- If there’s a sensible, non-null default value that makes sense in the overwhelming majority of calls — use a default parameter. Timeout durations, retry counts, log levels, base URLs in a test environment config — these all have obvious sane defaults.
- If the absence of a value is itself meaningful — “the caller explicitly chose not to provide this” is different from “the caller provided this specific value” — use an TypeScript optional parameter and handle
undefinedexplicitly inside the function. - If you’re tempted to default to
nullor an empty string as a sentinel for “nothing was passed,” stop and ask whether an optional parameter with realundefined-checking is more honest about what’s actually happening in your code.
A concrete Playwright example makes this concrete. Consider a custom assertion helper:
async function assertElementVisible(
page: Page,
selector: string,
timeoutMs: number = 10000,
customErrorMessage?: string
): Promise<void> {
try {
await page.waitForSelector(selector, { state: 'visible', timeout: timeoutMs });
} catch (error) {
const message = customErrorMessage ?? `Element "${selector}" was not visible within ${timeoutMs}ms`;
throw new Error(message);
}
}
timeoutMs is a default parameter because 10 seconds is a genuinely sensible default for most assertions in most test suites, and giving it a real numeric default means every downstream calculation involving timeoutMs can trust it’s a number without a null check. customErrorMessage, on the other hand, is an TypeScript optional parameter, because there’s no sensible default error message string — the absence of a custom message is meaningfully different from an empty string, and I want the nullish coalescing operator to make that decision explicit at the point of use rather than baking a slightly-wrong generic string into the parameter list itself.
Parameter Ordering Rules You Cannot Violate
TypeScript enforces a rule that trips up beginners constantly: required parameters must come before optional and default parameters in the parameter list, with one narrow exception involving explicit undefined unions that I’ll cover below. This will not compile:
// Compile error: A required parameter cannot follow an TypeScript optional parameter.
function login(username?: string, password: string): void {}
The reasoning is straightforward once you think about how JavaScript resolves positional arguments — if optional parameters could precede required ones, the compiler would have no reliable way to know whether an omitted middle argument means “skip this one” or “this is actually the next positional slot.” JavaScript doesn’t support named arguments the way Python or Kotlin does (outside of destructured object parameters, which I’ll get to), so position is everything, and TypeScript’s ordering rule exists purely to keep that positional resolution unambiguous.
There’s a narrow escape hatch worth knowing for interviews: you technically can put a required parameter after a parameter typed as an explicit union with undefined, as long as it’s not marked with the ? optional modifier:
function configureRetry(strategy: string | undefined, maxAttempts: number): void {}
configureRetry(undefined, 3); // valid — caller must explicitly pass undefined
This is different from marking strategy as truly optional with ?, because here the caller is required to pass something in that slot — even if that something is the literal value undefined — which keeps positional resolution unambiguous for the compiler. It’s a subtle distinction, and it’s exactly the kind of thing that shows up as a “explain the difference between these two signatures” interview question, so I’ll flag it again in the interview-prep section with a cleaner side-by-side.
The Trap: TypeScript Optional Parameters and the strictNullChecks Flag
This is where I see the most real-world bugs, and it’s worth spending real time on because most tutorials treat this as a footnote when it should be a headline. If your tsconfig.json doesn’t have strictNullChecks enabled (or isn’t using the broader strict flag, which implies it), TypeScript will not force you to guard against undefined before using an TypeScript optional parameter. Consider:
// Without strictNullChecks, this compiles cleanly and fails at runtime
function retryAction(action: () => void, maxAttempts?: number): void {
for (let i = 0; i < maxAttempts; i++) { // maxAttempts could be undefined here
action();
}
}
Without strict null checks on, comparing i < maxAttempts when maxAttempts is undefined doesn’t throw a compile error — it just silently evaluates to false at runtime because any numeric comparison against undefined resolves to NaN-driven falsiness, and your retry loop quietly executes zero times instead of throwing or retrying. I’ve seen exactly this class of bug in a real automation framework: a “retry flaky assertion” helper that was supposed to attempt three times by default, silently ran zero times whenever a specific call site forgot to pass the count, and nobody noticed for weeks because the test still “passed” — it just wasn’t actually retrying anything, and the flaky test it was meant to stabilize kept intermittently failing in CI with a root cause nobody connected back to the retry helper for a surprisingly long time.
With strictNullChecks enabled — which I consider close to non-negotiable for any serious test automation framework, and which the official tsconfig reference documents alongside the rest of the strict-mode family of flags — that same loop is a compile error, because the compiler correctly refuses to let you compare a number against a value that might be number | undefined without a guard. You’d be forced to write:
function retryAction(action: () => void, maxAttempts: number = 3): void {
for (let i = 0; i < maxAttempts; i++) {
action();
}
}
Which, notice, is not even really about adding a null check — it’s about recognizing that this was never actually an “optional parameter” problem to begin with. It was a default parameter problem wearing an TypeScript optional parameter’s clothes. This is the single most common misuse I see in code review: engineers reach for ? out of habit or because it “feels” more flexible, when what they actually want, semantically and practically, is a default value with a real fallback. If you take away one rule from this entire article, take this one: reach for a default parameter first, and only fall back to a true TypeScript optional parameter when the absence of a value needs to be distinguishable from any possible present value, including the type’s own default.
Default Parameters Referencing Earlier Parameters
A capability that surprises engineers coming from languages with stricter parameter-list rules: a default parameter expression can reference parameters that were declared before it in the same parameter list.
function buildApiUrl(basePath: string, endpoint: string, fullUrl: string = `${basePath}${endpoint}`): string {
return fullUrl;
}
buildApiUrl('/api/v1', '/users'); // "/api/v1/users"
buildApiUrl('/api/v1', '/users', '/api/v2/users-override'); // "/api/v2/users-override"
This works because of the order in which JavaScript resolves the parameter list at call time — each default expression is evaluated left to right, and by the time it reaches fullUrl‘s default, both basePath and endpoint already have concrete values bound. It’s a genuinely useful pattern for building composite defaults out of already-provided arguments, but it has a sharp edge: you cannot reference a parameter that comes after the current one in the list, because it hasn’t been resolved yet at that point in the evaluation order. Trying to do so is a compile error, and it’s worth internalizing why, rather than memorizing it as an arbitrary rule — the evaluation order is strictly left to right, full stop, and default expressions are just regular JavaScript expressions evaluated in that same left-to-right pass.
Destructured Parameters With Defaults: The Pattern You’ll Use Constantly
Once a function accumulates more than two or three optional configuration values, positional parameters stop being ergonomic — nobody wants to call launchBrowser(true, false, 30000, undefined, 'chromium') and have to count commas to figure out what the fourth argument means. This is where destructured object parameters with defaults become the dominant pattern in real test automation code, and it’s a pattern you’ll recognize instantly if you’ve used Playwright’s own APIs, because Playwright itself leans on this convention heavily.
interface LaunchOptions {
headless?: boolean;
slowMo?: number;
browserName?: 'chromium' | 'firefox' | 'webkit';
recordVideo?: boolean;
}
function launchTestBrowser({
headless = true,
slowMo = 0,
browserName = 'chromium',
recordVideo = false
}: LaunchOptions = {}): void {
console.log(`Launching ${browserName}, headless=${headless}, slowMo=${slowMo}, video=${recordVideo}`);
}
launchTestBrowser();
launchTestBrowser({ headless: false, slowMo: 250 });
launchTestBrowser({ browserName: 'webkit', recordVideo: true });
There are two separate default mechanisms stacked here, and conflating them is a very common source of confusion. The = {} after the destructuring pattern and the LaunchOptions type annotation is a default for the entire parameter object — it means the caller can omit the argument entirely and get an empty object to destructure against. The individual headless = true, slowMo = 0, and so on inside the destructuring braces are defaults for each individual property, applied if that specific property is missing or explicitly undefined on whatever object gets passed in (or the empty-object fallback). Miss the outer = {} and calling launchTestBrowser() with no arguments throws a runtime error trying to destructure properties off of undefined — a mistake I’ve watched trip up engineers who correctly set individual property defaults but forgot the container itself also needs one.
This pattern is genuinely how you should be designing most configuration-heavy test utility functions, Page Object constructors, and API client wrapper methods once they cross roughly three optional parameters. It reads at the call site almost like named arguments, which JavaScript doesn’t natively support, and it means adding a new optional configuration flag later never requires touching existing call sites, since order stops mattering entirely — a real advantage over positional TypeScript optional parameters as an API surface grows over time.
Optional Properties vs. TypeScript Optional Parameters: A Distinction Interviewers Love to Probe
It’s worth being precise about vocabulary here because interview panels frequently test whether candidates conflate these two related-but-distinct concepts. An optional parameter is a function parameter that can be omitted at the call site, marked with ? in the parameter list. An optional property is a property on an interface or type that can be omitted when constructing an object of that type, also marked with ?, but in a completely different syntactic position:
interface TestConfig {
baseUrl: string;
timeout?: number; // optional property
retries?: number; // optional property
}
function runTests(config: TestConfig): void {
const timeout = config.timeout ?? 30000;
const retries = config.retries ?? 2;
// ...
}
function runTestsAlt(baseUrl: string, timeout?: number): void {
// timeout here is an TypeScript optional parameter, not a property
}
They look nearly identical — same question mark, same semantic idea of “this can be absent” — and that similarity is exactly why the destructured-parameter pattern from the previous section feels so natural: you’re frequently combining an interface full of optional properties with a function that destructures that interface as its parameter, and the two concepts blend together at the call site even though they’re doing distinct jobs in the type system. Understanding that a TestConfig object with optional properties and a function with TypeScript optional parameters are governed by slightly different rules (interface optional properties, for instance, interact with Partial<T> and structural typing in ways parameter lists don’t) is exactly the kind of nuance that separates a “I’ve used TypeScript” answer from a “I understand TypeScript’s type system” answer in an interview setting.
Optional Parameters and Function Overloads
A more advanced pattern, and one that comes up specifically when you’re designing a test utility library meant to be consumed by other engineers on your team, is combining TypeScript optional parameters with explicit function overload signatures for cases where a single optional-parameter signature doesn’t capture the real relationship between arguments.
function findElement(selector: string): Promise<ElementHandle>;
function findElement(selector: string, timeout: number): Promise<ElementHandle>;
function findElement(page: Page, selector: string, timeout?: number): Promise<ElementHandle> {
// implementation
return page.waitForSelector(selector, { timeout: timeout ?? 5000 });
}
Overloads exist for cases a single optional-parameter signature can’t express cleanly — most commonly when the presence of one parameter should change the required type of another, not just make an argument optional. It’s a heavier tool than a plain TypeScript optional parameter, and I’d caution against reaching for it by default: for the vast majority of test automation utility functions, a well-designed destructured-options parameter with sensible defaults gets you further with less maintenance cost than a stack of overload signatures. But knowing when overloads are the right call — and being able to articulate why plain optional parameters weren’t sufficient in a given case — is a good signal of design maturity, and it’s a topic senior-level interviews do probe.
Rest Parameters: The Third Sibling
No discussion of optional and default parameters is complete without at least placing rest parameters in the family, because engineers frequently reach for TypeScript optional parameters when what they actually need is a rest parameter, especially when building logging or assertion utilities that need to accept a variable number of arguments.
function logTestStep(stepName: string, ...details: string[]): void {
console.log(`[STEP] ${stepName}`, details.length ? details.join(', ') : '');
}
logTestStep('Navigate to login page');
logTestStep('Fill credentials', 'username=testuser', 'field=#username-input');
Rest parameters must come last in the parameter list, cannot have a default value or optional marker of their own (they’re inherently “zero or more,” which already subsumes optionality), and are typed as an array. They solve a genuinely different problem than optional or default parameters — an unbounded, homogeneous tail of arguments, versus a fixed, small number of individually-typed slots that may or may not be provided. MDN’s rest parameters documentation covers the underlying JavaScript mechanics in full, and it’s worth reading alongside the default parameters page linked earlier, because the two features were introduced in the same ECMAScript 2015 release and share a fair amount of evaluation-order logic.
Common Mistakes I See in Code Review, Ranked by How Often They Cause Real Bugs
Let me walk through the mistakes I actually flag in pull requests, in roughly descending order of how much production or CI-pipeline pain they’ve caused in codebases I’ve worked on or reviewed.
1. Using ? When You Meant a Default
Covered above at length, but it’s worth repeating as the single highest-frequency mistake: an TypeScript optional parameter that gets immediately followed by a fallback expression on nearly every call site (const t = timeout ?? 5000; written inside the function body itself, not left to the caller) is a strong signal that the parameter should have been a default parameter from the start. The tell is simple — if the function itself, not the caller, decides what happens when the value is absent, and that decision is always the same value, make it a default parameter and remove the null-handling entirely.
2. Forgetting the Outer Default on Destructured Object Parameters
Also covered above: function f({ a = 1 }: Options) without a trailing = {} throws at runtime the moment someone calls f() with zero arguments, because you can’t destructure properties off of undefined. This is a mistake that TypeScript’s type checker frequently won’t catch for you if the parameter itself isn’t marked optional at the type level — it’s a pure runtime footgun, and I’ve watched it slip past code review because the diff looked clean and the individual property defaults looked correct in isolation.
3. Assuming Default Expressions Are Cached
Also covered above with the timestamp example — default expressions re-evaluate on every call. If your default value needs to be computed once and shared, compute it outside the function and reference a closed-over variable, or pass it explicitly. Don’t rely on a default parameter expression to behave like a memoized value.
4. Mixing Optional Parameters With Non-Null Assertions Instead of Real Guards
I see this constantly in codebases under deadline pressure:
function submitForm(page: Page, retryCount?: number): void {
for (let i = 0; i < retryCount!; i++) { // the ! is doing something dangerous here
// ...
}
}
The non-null assertion operator (!) tells the compiler “trust me, this is never actually undefined,” which is precisely the kind of promise that quietly breaks the exact moment someone calls this function without the second argument, six months later, having never read this line of code. It compiles cleanly, it looks confident, and it is functionally identical to the pre-strictNullChecks bug I described earlier — you’ve just manually disabled the safety net the compiler was trying to give you. If you find yourself reaching for ! to silence a complaint about an TypeScript optional parameter, that’s almost always a sign you actually wanted a default parameter, not a suppressed type error.
5. Overusing TypeScript Optional Parameters Instead of Splitting Into Two Functions
Sometimes the honest fix isn’t a default parameter or better guarding — it’s recognizing that a function with four optional parameters controlling wildly different behavior branches internally should be two or three smaller functions instead. I’ve reviewed Page Object methods like fillForm(data, skipValidation?, submitAfter?, waitForNavigation?) where each optional flag flips a completely different code path, and the resulting function was more if/else branching than actual logic. Splitting it into fillFormAndSubmit and fillFormOnly, each with its own smaller, clearer parameter list, was a better design than trying to parametrize the branching away with three more booleans. TypeScript optional parameters are a tool for genuine configuration variance, not a substitute for admitting a function is doing two distinct jobs.
Bringing It Back to Real Test Automation Code
Let’s build out a more complete, realistic example — a base Page Object class constructor and a handful of its methods — to show how optional and default parameters actually compose in a framework you might genuinely maintain.
interface NavigationOptions {
waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';
timeoutMs?: number;
}
class BasePage {
constructor(protected readonly page: Page, protected readonly baseUrl: string = 'https://staging.example.com') {}
async goto(path: string, { waitUntil = 'load', timeoutMs = 30000 }: NavigationOptions = {}): Promise<void> {
await this.page.goto(`${this.baseUrl}${path}`, { waitUntil, timeout: timeoutMs });
}
async clickAndWait(selector: string, expectedUrlFragment?: string, timeoutMs: number = 5000): Promise<void> {
await this.page.click(selector);
if (expectedUrlFragment) {
await this.page.waitForURL(`**${expectedUrlFragment}**`, { timeout: timeoutMs });
}
}
async fillField(selector: string, value: string, options: { clearFirst?: boolean } = {}): Promise<void> {
const { clearFirst = true } = options;
if (clearFirst) {
await this.page.fill(selector, '');
}
await this.page.fill(selector, value);
}
}
Notice the deliberate choices in this small class. The constructor’s baseUrl is a default parameter, because “staging” is genuinely the sensible default environment for most test runs, and every method that uses this.baseUrl can trust it’s a real string without a null check. goto‘s navigation options use the destructured-object-with-outer-default pattern because there are multiple independent, named configuration knobs that would be unreadable as bare positional parameters. clickAndWait‘s expectedUrlFragment is a true TypeScript optional parameter, not a default, because there genuinely isn’t a sensible default URL fragment — the absence of that argument means “don’t bother waiting for a URL change,” which is meaningfully different behavior, not just a missing string. And fillField‘s clearFirst defaults to true because clearing a field before filling it is the safer default behavior for most form-filling scenarios, with an explicit opt-out for the rare case you don’t want it. Every single one of those four choices was made using the decision framework from earlier in this article, not by habit — and being able to articulate that reasoning, out loud, in a design review, is worth more to your career than knowing the syntax itself.
How This Plays Out Across REST API Test Clients
The same reasoning applies just as cleanly outside the browser-automation world, in API test client design, where I’d argue the stakes around getting optional-versus-default right are even higher, because API test clients tend to get reused across dozens or hundreds of test files, and a design mistake here compounds fast.
interface RequestOptions {
headers?: Record<string, string>;
timeoutMs?: number;
expectedStatus?: number;
}
class ApiTestClient {
constructor(private readonly baseUrl: string, private readonly defaultHeaders: Record<string, string> = {}) {}
async get(path: string, { headers = {}, timeoutMs = 10000, expectedStatus = 200 }: RequestOptions = {}) {
const response = await fetch(`${this.baseUrl}${path}`, {
headers: { ...this.defaultHeaders, ...headers },
signal: AbortSignal.timeout(timeoutMs)
});
if (response.status !== expectedStatus) {
throw new Error(`Expected status ${expectedStatus}, got ${response.status} for GET ${path}`);
}
return response.json();
}
}
The expectedStatus = 200 default here is doing real work beyond convenience — it encodes an assumption directly into the type-checked, default-driven parameter list: most GET requests in a healthy test suite expect a 200. Tests verifying error responses explicitly override it to 404, 401, or whatever the scenario demands, and that override reads clearly at the call site: client.get('/users/999', { expectedStatus: 404 }). Compare that to a version where expectedStatus was a true optional parameter with an if (expectedStatus !== undefined) check buried in the method body — functionally similar, but it hides the “200 is the assumed happy path” decision inside implementation logic instead of stating it plainly in the signature where every consumer of the client can see it without reading the method body at all. That’s the deeper value default parameters bring to test framework design: they make your defaults part of the documented, type-checked API surface, not an implementation detail a caller has to go dig for.
Interview Prep: Questions I’ve Actually Been Asked (and Asked Others)
Since a large part of this blog’s audience is actively interview-prepping for SDET, QA automation, and test architecture roles, here’s a working set of questions on this exact topic, pulled from real interview loops I’ve either sat on or prepped candidates for.
“What’s the difference between TypeScript optional parameters and default parameters?”
The answer that gets full marks: an TypeScript optional parameter (param?: Type) can be omitted at the call site and has type Type | undefined inside the function body, requiring the function to explicitly handle the absent case. A default parameter (param: Type = value) can also be omitted at the call site, but TypeScript automatically substitutes the default value when omitted, so inside the function body the parameter retains its plain Type — no undefined to guard against. The weaker answer, the one that only gets partial credit, is “they both let you skip an argument” without mentioning the type-narrowing difference — that’s the detail that shows you actually understand the type system rather than just the calling convention.
“Can you have a default parameter before a required parameter?”
No, not with the ? or = value mechanisms — required parameters must precede optional and default parameters in the list, because JavaScript resolves arguments positionally and the compiler needs an unambiguous way to know which omitted slot maps to which parameter. The one nuance worth mentioning for extra credit: you can place a required parameter after one typed as an explicit Type | undefined union (as opposed to one marked with the ? optional modifier), because the caller is still required to supply something in that earlier slot, even if that something is the literal value undefined.
“Does JavaScript support default parameters, or is this TypeScript-only?”
Default parameters are a JavaScript language feature, standardized in ECMAScript 2015 (ES6), predating TypeScript’s adoption of them by years. TypeScript adds compile-time type checking on top of the same runtime behavior — the substitution logic (including re-evaluation on every call, and the ability to reference earlier parameters) is pure JavaScript semantics that TypeScript inherits, not something TypeScript invented. This is a frequently mis-answered question because candidates who learned TypeScript first, without a solid JavaScript foundation, sometimes assume every ergonomic feature they use is TypeScript-specific.
“Why would a compiler let you write i < someOptionalNumber without an error?”
This is a trick question testing whether the candidate knows about the strictNullChecks compiler flag. Without it enabled, TypeScript doesn’t force you to guard against undefined before using an optional parameter in an expression like a numeric comparison, and the code will compile even though it can produce incorrect behavior at runtime (a comparison against undefined resolves in a way that silently short-circuits loops rather than throwing). With strictNullChecks on — which should be considered close to mandatory for any serious codebase — that same line is a compile error, forcing an explicit guard or a default value instead.
“Write a function signature for a Playwright helper that waits for an element, with a configurable timeout that defaults to 5 seconds and an optional custom error message.”
This is a practical coding-round question, and the model answer is close to the assertElementVisible example earlier in this article: timeoutMs as a default parameter set to 5000, and customErrorMessage as a true TypeScript optional parameter, combined with the nullish coalescing operator to fall back to a generated message only at the point of use inside a catch block. Interviewers asking this question are typically listening for whether you reach for a default versus an TypeScript optional parameter appropriately, not just whether the code compiles.
A Professional’s Take
If there’s a single mental shift I’d want every engineer reading this to walk away with, it’s this: optional and default parameters aren’t two flavors of the same convenience feature, they’re two different answers to two different questions. “Can the caller skip this?” is answered by both. “What happens inside my function when they do?” is answered very differently by each, and that second question is where the real design decision lives. A default parameter says “I, the function author, have a confident opinion about what should happen here, and I’m encoding that opinion directly into the type-checked signature.” An optional parameter says “the absence of a value is itself meaningful information that my function needs to handle explicitly, and I’m not going to paper over that with a guess.” Every time you type a ? in a parameter list, ask yourself honestly which of those two things you actually mean — and if you catch yourself immediately writing a fallback expression on the very next line, you’ve just told yourself the answer.
This is the kind of distinction that doesn’t show up as a compiler error when you get it wrong, which is exactly why it’s worth internalizing rather than relying on tooling to catch it for you. The compiler will happily let you build a framework full of misused TypeScript optional parameters that “work” in every test you happen to write during development, and it’ll only reveal the design mistake months later, when a new team member calls your function in a way you never tested and hits the exact undefined-shaped gap you left for them. Get the distinction right at design time, and it’s one less category of bug your test automation framework will ever produce.
How TypeScript Optional Parameters Compare to C# and Java Optional Parameters
A meaningful chunk of this blog’s readership comes from a Selenium-with-C# or Selenium-with-Java background making the jump to Playwright and TypeScript, and I get asked some version of “how is this different from what I already know” often enough that it deserves its own section rather than a passing mention.
C# has had optional parameters since C# 4.0, and the syntax looks deceptively close to TypeScript’s default parameters:
// C#
public void WaitForElement(string selector, int timeoutMs = 5000)
{
// ...
}
Structurally this is nearly identical to a TypeScript default parameter, and the behavior at the call site is similar too — omit the argument, get the default. But there are real differences worth knowing cold if you’re translating mental models across languages. First, C#’s TypeScript optional parameter default values must be compile-time constants — you cannot write int timeoutMs = ComputeDefaultTimeout() in C#, because the default has to be resolvable at compile time and baked into the calling assembly’s IL, not evaluated fresh at runtime. TypeScript has no such restriction; as shown earlier in this article, a TypeScript default parameter can be an arbitrary expression, including a function call, evaluated fresh on every invocation. This is a genuine semantic difference, not just a syntax difference, and it’s caught more than one engineer off guard when they tried to port a “generate a fresh correlation ID as the default” pattern from TypeScript back into a C# helper library and discovered the compiler flatly rejects it.
Second, C# supports true named arguments as a first-class calling convention — WaitForElement(selector: "#btn", timeoutMs: 8000) — which means C# TypeScript optional parameters can appear in any order at the call site as long as you name them. TypeScript and JavaScript have no native named-argument syntax; the closest equivalent is the destructured object parameter pattern covered earlier in this article, which achieves a similar readability and reordering benefit but is a different mechanism entirely — it’s a single positional parameter that happens to be an object literal, not multiple independently named parameters. If you’re coming from C# and reaching for what feels like “the same thing,” recognize that the destructured-object pattern is TypeScript’s structural answer to a problem C# solves at the language-syntax level.
Java, notably, has no native optional or default parameter syntax at all — a gap that surprises engineers moving in the other direction, from TypeScript back toward Java, since Java parameter lists are always fully required for a given method signature. Java’s idiomatic substitute is method overloading: you write multiple methods with the same name and different parameter lists, each one calling through to a “full” implementation with explicit defaults filled in.
// Java
public void waitForElement(String selector) {
waitForElement(selector, 5000);
}
public void waitForElement(String selector, int timeoutMs) {
// actual implementation
}
This is functionally similar to what a default parameter gives you for free in a single signature, but it costs you a method per meaningful combination of omitted arguments, and it doesn’t scale gracefully once you have more than one or two optional values — you either write a combinatorial explosion of overloads, or you introduce a builder pattern, which is exactly why the builder pattern is so much more common in Java test framework code than it is in TypeScript or JavaScript test framework code. When engineers ask me why Playwright’s TypeScript API leans so heavily on options objects rather than a builder-style fluent API the way some Java Selenium wrappers do, this is the underlying reason: TypeScript’s destructured-default-parameter pattern already gives you most of what a builder buys you in Java, without the extra ceremony of a separate builder class.
Optional and Default Parameters With Generics
Once you start writing generic test utility functions — the kind that work across multiple Page Object types or multiple API response shapes — optional and default parameters interact with type parameters in ways that are worth understanding explicitly, because the errors you get when this goes wrong are some of the least intuitive in the entire language.
function retryUntil<T>(
action: () => Promise<T>,
predicate: (result: T) => boolean,
maxAttempts: number = 5,
delayMs: number = 1000
): Promise<T> {
return new Promise(async (resolve, reject) => {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const result = await action();
if (predicate(result)) {
resolve(result);
return;
}
await new Promise(r => setTimeout(r, delayMs));
}
reject(new Error(`Predicate not satisfied after ${maxAttempts} attempts`));
});
}
const orderStatus = await retryUntil(
() => apiClient.get<OrderStatus>('/orders/1234/status'),
status => status.state === 'CONFIRMED'
);
Here T is inferred entirely from the action and predicate arguments — TypeScript’s inference engine doesn’t need any help from maxAttempts or delayMs to figure out what T is, because those two parameters have no relationship to the generic type at all, and that’s exactly why it’s safe to give them defaults without disturbing inference. The pitfall shows up when a generic type parameter’s inference does depend on a parameter you’re tempted to make optional or default. Consider a naive attempt at a generic API client method with a default response type:
// Fragile: what does T resolve to if the caller omits every hint?
function get<T = unknown>(path: string, options?: RequestOptions): Promise<T> {
// ...
}
const users = await get('/users'); // T inferred as `unknown` here, not `User[]`
Giving a generic type parameter a default (T = unknown) is a completely different mechanism from giving a function parameter a default value, even though the syntax looks similar — this default kicks in only when TypeScript has no other way to infer T, and in the example above, since nothing in the function’s actual parameters constrains T, every call site silently falls back to unknown unless the caller explicitly supplies the type argument: get<User[]>('/users'). This is a genuinely common design mistake in hand-rolled API test clients — the generic type parameter looks like it should be inferred from context, but without a parameter whose type actually depends on T (like a callback that receives a T, or a schema object typed as T), there’s nothing for inference to grab onto, and the generic default silently papers over what should have been a compile error reminding the caller to specify the type explicitly.
Async Default Parameters and the await Trap
A specific edge case worth calling out because it comes up constantly in Playwright and API test code: default parameter expressions cannot use await, because the parameter list of a function is evaluated synchronously as part of invoking the function, regardless of whether the function body itself is declared async.
async function getAuthToken(): Promise<string> {
// ...fetches a token
}
// This does NOT work the way it looks like it should
async function apiRequest(path: string, token: string = await getAuthToken()): Promise<Response> {
// Compile error: 'await' expressions are only allowed within async functions
// and at the top levels of modules — and a default parameter expression
// is neither, even inside an async function.
}
This surprises engineers who reasonably assume that because the enclosing function is marked async, every expression within its signature inherits that async context. It doesn’t — the parameter list default expressions are evaluated in a separate synchronous scope from the function body, a quirk of how the language spec defines function parameter evaluation. The practical fix is to move the fallback logic inside the async function body, using a true optional parameter instead of a default:
async function apiRequest(path: string, token?: string): Promise<Response> {
const authToken = token ?? await getAuthToken();
// ...
}
This is a small, sharp-edged rule, but I’ve watched it cost engineers real debugging time specifically because the compiler error message doesn’t obviously point at “your function signature has an async operation in the wrong scope” — it just complains about await being disallowed in that position, and if you don’t already know default parameter expressions live outside the async body, that error reads as confusing rather than clarifying.
TypeScript Optional Parameters in Callback and Function Type Signatures
Everything covered so far has focused on parameters in a function you’re defining and calling directly. But TypeScript optional parameters show up just as often — arguably more often, in a mature test framework — in the shape of callback types you’re consuming, and there’s a specific variance rule here that’s genuinely one of the more counterintuitive corners of TypeScript’s type system.
type ResponseHandler = (response: Response, request?: Request) => void;
// Both of these are valid implementations of ResponseHandler:
const handlerA: ResponseHandler = (response) => {
console.log(response.status());
};
const handlerB: ResponseHandler = (response, request) => {
console.log(response.status(), request?.url());
};
TypeScript allows a function with fewer parameters to satisfy a type expecting more parameters, as long as the extra parameters in the type are optional (or, in fact, even if they’re not marked optional at all — this is a deliberate, if slightly surprising, leniency baked into function type compatibility, sometimes called “parameter bivariance” territory, though the strict details vary depending on whether you’re comparing method syntax or function-property syntax). This is exactly why page.on('response', (response) => {...}) compiles fine in Playwright even though the underlying event handler type signature technically includes more parameters than you’re using — the callback type system is deliberately permissive about callers implementing a subset of the full parameter list, because in practice this is almost always what you want: you only destructure or name the parameters you actually care about. Where this gets genuinely tricky is the reverse direction — supplying a function with more required parameters than the type expects is not allowed, because the caller of that callback (Playwright’s internals, in this case) has no way to know it needs to supply an argument your implementation demands but the type contract never promised. Understanding this asymmetry is what separates “I copy the callback signature from the docs” from “I understand why the docs’ callback signature is shaped the way it is.”
Testing the Optional and Default Parameter Behavior Itself
Given how much of this article has been about design decisions embedded directly in a function signature, it’s worth spending a section on how you actually verify that behavior with tests, because “the types compile” and “the runtime behavior is correct” are two different claims, and only one of them is checked by the compiler.
import { describe, it, expect } from 'vitest';
describe('waitForElement timeout defaulting', () => {
it('uses the default 5000ms timeout when none is provided', async () => {
const page = createMockPage();
const waitForSelectorSpy = vi.spyOn(page, 'waitForSelector');
await waitForElement(page, '#submit-button');
expect(waitForSelectorSpy).toHaveBeenCalledWith('#submit-button', { timeout: 5000 });
});
it('respects an explicitly provided timeout', async () => {
const page = createMockPage();
const waitForSelectorSpy = vi.spyOn(page, 'waitForSelector');
await waitForElement(page, '#submit-button', 12000);
expect(waitForSelectorSpy).toHaveBeenCalledWith('#submit-button', { timeout: 12000 });
});
});
Notice that this test suite is explicitly asserting on the substituted value, not just that the call didn’t throw — the whole point of a default parameter is that “no argument provided” and “the default value provided explicitly” produce identical observable behavior, and a spy assertion is the cleanest way to prove that equivalence rather than assume it. I treat this as close to mandatory for any shared test utility function whose default value encodes a real design decision (like the “assume 200 unless told otherwise” example from the API client section earlier) — if that default is ever silently changed during a refactor, I want a test to catch it, because a shift in an unspoken default is exactly the kind of change that’s invisible in a diff review unless someone happens to scroll to that one line, but visible immediately across dozens of test failures once the wrong status code starts getting assumed everywhere.
Where AI Code Generation Tools Get This Wrong
Given how much of day-to-day test automation work now involves AI-assisted code generation — Copilot, Claude, or similar tools suggesting entire function signatures inline — it’s worth flagging a pattern I run into constantly when reviewing AI-suggested code in pull requests: generated helper functions default to marking almost everything optional with ?, rarely reach for a genuine default parameter unless explicitly prompted, and almost never use the destructured-object-with-outer-default pattern unless the surrounding codebase already demonstrates it heavily in context. This isn’t a knock on any specific tool — it’s a reasonable reflection of how much ambiguous, loosely-typed JavaScript exists in the training data these models learn from, where ? is the path of least resistance and reaching for a genuinely opinionated default requires understanding intent the model doesn’t have without being told. The practical implication for anyone reviewing AI-generated test automation code, or accepting inline suggestions while writing it: treat every AI-suggested optional parameter as a prompt for the exact same question I’ve been asking throughout this article — is the absence of this value meaningful, or did the model just default to the laziest syntactically-valid option? I flag this specifically because it’s become one of the most common code review comments I leave on AI-assisted PRs in the frameworks I maintain, and it’s exactly the kind of judgment call that no amount of autocomplete replaces.
A Quick-Reference Decision Table
For a topic this nuanced, a compact reference is genuinely useful once you’ve internalized the reasoning behind it, so here’s the summary I’d want pinned above my desk if I were still ramping up on this:
- Use a default parameter when there’s a single, confident, sensible value that should apply whenever the caller doesn’t specify otherwise, and you want the parameter to be a plain, non-nullable type inside the function body.
- Use a true TypeScript optional parameter when the absence of a value is itself meaningful information the function needs to branch on, and no single default value would be honest about that absence.
- Use a destructured object parameter with an outer default once you have more than two or three optional or default values, to keep call sites readable and order-independent, and to make adding new options non-breaking for existing callers.
- Use a rest parameter when you need an unbounded, homogeneously-typed tail of arguments, not a small fixed set of individually-meaningful slots.
- Use function overloads only when the presence of one parameter should change the required type of another — not as a general substitute for TypeScript optional parameters.
- Always enable
strictNullChecks(or the broaderstrictflag) in any test automation framework’stsconfig.json, because it’s the single setting that turns the most dangerous optional-parameter mistake in this entire article from a silent runtime bug into a compile-time error.
Extended Interview Prep: Scenario-Based Questions
Beyond the direct definitional questions covered earlier, senior-level interviews increasingly favor scenario-based prompts where you’re handed a flawed function signature and asked to critique it. Here are a few I’ve used or encountered, along with the reasoning a strong answer should include.
“Here’s a function signature: function createUser(name: string, email?: string, role: string = 'tester'). What’s wrong with it?”
This won’t compile, and the reason is the ordering rule covered earlier — role, a default parameter, appears after email, an optional parameter, which is fine in terms of ordering relative to each other (both are “omittable” categories and can be adjacent), but the real question is whether the candidate notices something more subtle: if a caller wants to supply role without supplying email, they’re forced to explicitly pass undefined for the second slot — createUser('Jane', undefined, 'admin') — which is exactly the ergonomic cost of positional optional and default parameters once you have more than one of them. The strong answer identifies that this function is a better candidate for the destructured-object pattern precisely because it has multiple independently-omittable parameters, and explains that as the actual fix, not just “add a default value somewhere.”
“A teammate wrote function delay(ms: number = 1000): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)); } and complains it’s ‘basically the same’ as making ms optional and checking for undefined inside. Are they right?”
Functionally, for this specific trivial case, the observable behavior at the two call sites (delay() and delay(2000)) is identical either way. But the strong answer pushes back on “basically the same” by pointing at the difference inside the function body: with the default parameter, every use of ms inside delay is a plain number, no guard needed, no risk of a stray NaN-comparison bug creeping in if the function grows more logic later. With an TypeScript optional parameter and a manual ms ?? 1000 fallback, you’ve introduced a spot where a future refactor could easily use the raw ms parameter before the fallback line runs, reintroducing an undefined-shaped bug the default-parameter version makes structurally impossible. “Basically the same” undersells a real difference in how much future-proofing against a specific bug class each version buys you.
“Explain why you can write function f(a: number, b: number = a * 2) but not function f(a: number = b * 2, b: number).”
This tests understanding of left-to-right evaluation order rather than rote memorization. The first version works because by the time the engine evaluates b‘s default expression, a has already been bound to a concrete value from either the supplied argument or (recursively) its own default. The second version is a compile error because a‘s default expression references b, which hasn’t been resolved yet at that point in the left-to-right pass — there’s no value for b to reference, regardless of whether b itself ends up being required or has its own default further down the list.
Where This Fits Into a Bigger TypeScript Mental Model
Optional and default parameters don’t exist in isolation — they’re one piece of a broader theme that runs through everything TypeScript does well: making implicit assumptions explicit and machine-checkable. The same instinct that should make you pause before marking a parameter optional is the instinct behind marking object properties readonly, behind preferring discriminated unions over loosely-typed flag objects, behind reaching for as const instead of a widened string type. Every one of these choices is really the same underlying question asked in a different syntactic position: what do I actually know to be true here, and what am I choosing to leave genuinely uncertain? A default parameter is a statement of confidence. An TypeScript optional parameter is a deliberate admission of uncertainty that the function commits to handling explicitly rather than assuming away. Getting comfortable making that distinction consciously, function by function, parameter by parameter, is a big part of what separates code that merely compiles from code a team can trust six months and three engineers later, long after the person who wrote the original signature has moved on to a different project.
That’s really the throughline of everything in this article: the syntax takes an afternoon to learn. Knowing which tool encodes which intent, and being able to defend that choice in a design review or an interview, takes deliberate practice — which is exactly why it’s worth writing (and reading) three thousand extra words about a topic that looks, on the surface, like it should fit in a five-minute cheat sheet.
A Migration War Story: Porting a Selenium C# Framework’s Optional-Parameter Sprawl
A few years back I inherited a Selenium WebDriver framework written in C# that had grown organically over roughly four years, maintained by a rotating cast of contractors, and was being ported to Playwright with TypeScript as part of a broader modernization push in a payments platform’s QA org. The C# base page class had a method that, by the time I got to it, looked something like this:
// C# — the "before" state
public void ClickElement(By locator, int timeoutSeconds = 10, bool scrollIntoView = true,
bool waitForClickable = true, string highlightColor = null, bool retryOnStaleElement = true)
{
// 40+ lines of branching logic depending on which combination of flags was set
}
Six optional parameters, five of them booleans, controlling branching logic that had accreted one flag at a time over years, each one added to solve a specific flaky test rather than as part of any coherent design. By the time I inherited it, nobody on the team could tell you, without reading the implementation, what ClickElement(locator, retryOnStaleElement: false) actually did differently from the default call, because the interactions between flags weren’t documented and in at least two cases weren’t even fully independent — setting waitForClickable to false silently made retryOnStaleElement a no-op, a fact buried in a nested if statement nobody had noticed in a design review because there hadn’t been one.
Porting this directly into TypeScript with the same six-optional-parameter shape would have been the easy path and the wrong one — it would have faithfully reproduced years of undocumented flag interactions in a new language, which is arguably worse than leaving it in C#, because it launders genuinely confusing legacy design as if it were a deliberate decision made fresh. What we actually did was spend two days with the team’s most senior manual-turned-automation tester walking through every call site in the existing test suite, categorizing which flag combinations were actually used in practice (it turned out to be four combinations out of the theoretical thirty-two), and redesigned the method as three smaller, honestly-named functions plus one shared options object for the genuinely orthogonal concerns:
interface ClickOptions {
timeoutMs?: number;
scrollIntoView?: boolean;
}
async function clickElement(page: Page, selector: string, options: ClickOptions = {}): Promise<void> {
const { timeoutMs = 10000, scrollIntoView = true } = options;
const element = page.locator(selector);
if (scrollIntoView) {
await element.scrollIntoViewIfNeeded();
}
await element.click({ timeout: timeoutMs });
}
async function clickElementWithRetry(page: Page, selector: string, options: ClickOptions & { maxRetries?: number } = {}): Promise<void> {
const { maxRetries = 3, ...clickOptions } = options;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await clickElement(page, selector, clickOptions);
return;
} catch (error) {
if (attempt === maxRetries) throw error;
}
}
}
The highlightColor flag, which had been used exactly twice in the entire codebase and only for manual debugging sessions, didn’t get ported into the framework’s core API at all — it became a one-off debug utility function instead, because giving a debugging convenience the same weight as a production-path parameter in the type signature was itself part of what made the original six-parameter method so hard to reason about. Playwright’s own built-in tracing and inspector tooling made the manual highlight-and-screenshot pattern mostly obsolete anyway, which is exactly the kind of thing you discover when you actually sit down and ask why each flag exists instead of mechanically translating syntax from one language to another.
I bring this up not because it’s a dramatic story — it isn’t, it’s a fairly mundane afternoon of archaeology — but because it’s the realistic version of what “understanding TypeScript optional parameters” actually buys you on a real team. It’s not really about the syntax. It’s about having a firm enough grip on what optional and default parameters are supposed to communicate that you notice when a signature has drifted away from communicating anything at all, and you have the design vocabulary to fix it properly instead of just porting the mess forward in a new syntax.
TypeScript Optional Parameters, Constructors, and Dependency Injection in Test Frameworks
Constructors deserve their own discussion because the stakes around getting optional-versus-default right compound differently than they do in a plain function — a constructor’s parameter choices affect every single instance created from that class for the lifetime of the object, not just one call.
class ApiTestClient {
private readonly httpClient: HttpClient;
private readonly logger: Logger;
constructor(
private readonly baseUrl: string,
httpClient?: HttpClient,
logger: Logger = new ConsoleLogger()
) {
this.httpClient = httpClient ?? new FetchHttpClient();
this.logger = logger;
}
}
Notice the deliberate asymmetry: httpClient is a true optional parameter, resolved manually inside the constructor body rather than as a plain default parameter, even though a default parameter (httpClient: HttpClient = new FetchHttpClient()) would look simpler at first glance. This is a common and important pattern in test frameworks that need to support dependency injection for unit-testing the framework itself — if httpClient‘s default were baked directly into the parameter list as a default parameter expression, that expression re-evaluates and constructs a brand-new FetchHttpClient instance on every single call where the argument is omitted, which is exactly what you want for a stateless helper function but is often the wrong call for a constructor dependency you might want to intercept, mock, or share across instances. Handling the fallback manually inside the constructor body, using a true TypeScript optional parameter plus ??, gives you a single, deliberate point of control over exactly when and how that default dependency gets constructed — useful if, say, you later want to memoize it, or swap the default implementation based on an environment flag, without touching the parameter list’s type signature at all.
logger, on the other hand, is a plain default parameter, because a fresh ConsoleLogger instance per test client is genuinely fine — loggers in a test framework are typically cheap, stateless-enough, and don’t carry the same “should this really be constructed fresh every time” question that an HTTP client, with its connection pooling and potential configuration cost, legitimately raises. The distinction here mirrors the broader theme of this entire article: it’s not about which syntax is shorter, it’s about which mechanism honestly represents what should happen when the value is absent, and for dependencies with any meaningful construction cost or shared-state implications, a true TypeScript optional parameter with a deliberate, visible fallback line usually beats an implicit default parameter expression, even though both compile to roughly the same call-site ergonomics.
Enforcing the Right Choice With ESLint
Given how much of this article has been about a judgment call that the compiler won’t make for you, it’s worth knowing that a meaningful chunk of the mechanical, non-judgment-call rules — the ordering rule especially — can be enforced automatically, and I’d strongly recommend wiring this into any shared test automation framework’s lint configuration rather than relying on code review to catch it every time.
// .eslintrc — relevant rules for parameter hygiene
{
"rules": {
"default-param-last": "error",
"@typescript-eslint/no-non-null-assertion": "warn"
}
}
The default-param-last rule, part of ESLint’s core rule set, catches cases where a parameter with a default value is declared before a parameter without one — which TypeScript’s compiler already partially enforces for the strict “required after optional” case, but ESLint’s version also flags stylistically confusing orderings that technically compile, like a default parameter followed by another default parameter followed by a required parameter typed with an explicit union that includes undefined, the narrow escape hatch mentioned earlier in this article. It’s a cheap, mechanical guardrail against exactly the kind of signature drift described in the migration war story above — nobody sets out to write a confusing six-parameter method, it happens one reasonable-looking addition at a time, and a lint rule that fires the moment ordering starts getting awkward is a genuinely effective early warning that a signature is accumulating complexity faster than it’s being reviewed.
The non-null-assertion warning is the other rule I’d flag as directly relevant to this article’s core theme — as covered in the “common mistakes” section earlier, reaching for ! on an optional parameter is very often a sign that a default parameter was the honest answer all along, and having that pattern generate a visible warning in CI, rather than relying on a human reviewer to notice it in a large diff, has caught real issues before they shipped in more than one framework I’ve maintained.
Serialization Gotchas: undefined vs. Optional Properties in JSON
Since a large part of this blog’s audience does API test automation alongside UI automation, it’s worth calling out a gotcha that sits right at the intersection of TypeScript optional parameters, optional properties, and JavaScript’s JSON.stringify behavior — because I’ve seen this cause genuinely confusing test failures that look like an API bug but are actually a client-side serialization quirk.
interface CreateOrderRequest {
productId: string;
quantity?: number;
couponCode?: string;
}
function buildCreateOrderPayload(productId: string, quantity?: number, couponCode?: string): CreateOrderRequest {
return { productId, quantity, couponCode };
}
const payload = buildCreateOrderPayload('SKU-123');
console.log(JSON.stringify(payload));
// {"productId":"SKU-123"} — quantity and couponCode are silently dropped, not sent as null
JSON.stringify omits object properties whose value is undefined entirely — it does not serialize them as null, and it does not include a key with an empty value. This is usually exactly what you want, and it lines up cleanly with how TypeScript optional parameters flow into optional properties, but it becomes a real test automation bug when the API under test actually distinguishes between “this field was omitted” and “this field was explicitly sent as null” — a distinction plenty of REST and GraphQL APIs genuinely make, particularly for PATCH-style partial-update endpoints where a missing field means “don’t touch this” and an explicit null means “clear this field.” If your test’s request-building helper uses optional parameters that flow straight into JSON.stringify without an explicit distinction, you may be structurally incapable of writing a test that verifies the “explicitly clear this field” behavior at all — every attempt to pass null through an TypeScript optional parameter that then gets assigned as undefined somewhere in the chain collapses back into “omitted” by the time JSON.stringify touches it. I’ve debugged exactly this class of failure in a PATCH endpoint test suite where a QA engineer was confident they’d written a test proving a “clear the discount code” scenario, and the request payload silently never contained the field at all — the API had nothing to act on, the endpoint correctly did nothing, and the test’s assertion happened to pass for the wrong reason entirely, which is a genuinely dangerous kind of false confidence to have sitting in a regression suite.
Frequently Asked Questions
Can a function have more than one TypeScript optional parameter?
Yes, as long as all optional and default parameters come after every required parameter in the list, in any order relative to each other. There’s no limit on how many you can declare, though as covered earlier in this article, once you’re past two or three, a destructured object parameter is almost always more maintainable than a long tail of individually optional positional parameters.
What happens if I explicitly pass undefined as the argument for a default parameter?
The default value kicks in exactly as if you’d omitted the argument entirely. This is a specific, deliberate behavior worth memorizing: function f(x: number = 5) {}; f(undefined); results in x being 5, not undefined, because default parameter substitution triggers on the value undefined specifically, not merely on argument omission. Passing null explicitly, by contrast, does not trigger the default — f(null) would be a type error against a plain number parameter (assuming strictNullChecks is on), because null and undefined are treated as distinct values by TypeScript’s default-substitution logic even though a casual reading might expect both to mean “nothing was given.”
Do optional parameters work the same way in arrow functions as in regular function declarations?
Yes — the ? and = value syntax behave identically regardless of whether you’re writing function f(x?: number) {} or const f = (x?: number) => {}. The only place this gets slightly more involved is when you’re annotating a standalone function type rather than a function expression, where the optional marker appears in the type signature itself: type Handler = (x?: number) => void;.
Can class methods use optional and default parameters the same way standalone functions do?
Yes, with no meaningful difference in the parameter-list rules themselves. The one place class methods add extra nuance is around method overriding in subclasses — TypeScript allows a subclass method to widen a parameter’s optionality (a base class requiring a parameter, a subclass making it optional) more freely than it allows narrowing, which follows the same variance logic as function type compatibility covered earlier in the callback section of this article.
Is there a performance cost to using default parameters versus manually checking for undefined?
No meaningful one in practice. Default parameters compile down to a simple typeof param === 'undefined' check (or equivalent) inserted at the top of the compiled function body — you can verify this yourself by pasting an example into the TypeScript Playground and inspecting the emitted JavaScript. This is exactly the check you’d write by hand with a true TypeScript optional parameter anyway; the default parameter syntax just generates it for you and, more importantly, gets the type system to track the result correctly, which is the actual value proposition, not any runtime performance difference.
Should I use default parameters in publicly exported library functions, or only in internal test helpers?
Both, but be more conservative about changing a default value in a public, widely-consumed function once it ships, because changing a default is a silent behavior change for every caller who was relying on omission — unlike adding a new TypeScript optional parameter, which is backward compatible by construction, adjusting an existing default value is not, and I’d treat it as a breaking change requiring the same care as a signature change, even though the type signature itself doesn’t change at all.
What’s the difference between a default parameter and using the nullish coalescing operator inside the function body?
Functionally, for a single primitive value, they can produce identical runtime behavior — function f(x: number = 5) versus function f(x?: number) { const resolved = x ?? 5; }. The meaningful difference, covered at length earlier in this article, is that the default parameter version gives you a plain, non-nullable number for the entire function body from the very first line, while the optional-plus-nullish-coalescing version only gives you that safety after the fallback line runs, leaving a window — however small — where a future refactor could accidentally use the raw, possibly-undefined x before the fallback executes.
Test Data Builders and Default Parameters
One more pattern worth covering explicitly, because it’s one of the most common places optional and default parameters show up in a mature test suite: test data builder functions, used to construct realistic-but-overridable fixture objects for both UI and API tests.
interface User {
id: string;
email: string;
role: 'admin' | 'standard' | 'guest';
isActive: boolean;
}
function buildTestUser(overrides: Partial<User> = {}): User {
return {
id: `user-${Math.random().toString(36).slice(2, 10)}`,
email: 'testuser@example.com',
role: 'standard',
isActive: true,
...overrides
};
}
const adminUser = buildTestUser({ role: 'admin' });
const inactiveUser = buildTestUser({ isActive: false, email: 'inactive@example.com' });
This is arguably the single most valuable practical application of everything covered in this article, condensed into one small, extremely reusable function. Partial<User> — a built-in TypeScript utility type that makes every property of User optional — combined with a default empty object and the spread operator gives you a builder where every property has a sensible baseline value, any subset can be overridden per test, and the return type is always the fully-formed User interface with no optional properties leaking into consuming code. This pattern scales cleanly to far more complex fixture shapes than the simple example above, and it’s exactly the kind of function where getting the “default versus optional versus required” decision right at the top level — a single optional overrides parameter defaulting to an empty object, rather than, say, four or five individually optional top-level parameters mirroring each field of User — pays for itself every single time someone reaches for this builder in a new test file, which in a mature framework can be hundreds of times.
Closing Thought: The Signature Is the First Thing Anyone Reads
Every function signature in a shared test automation framework gets read far more often than it gets written. You write it once. Every teammate who calls it, every future engineer debugging a failure that traces back through it, every code reviewer trying to understand a diff without opening the implementation, reads the signature first and makes assumptions based on it before ever looking at the body. A parameter marked optional, with no visible default, silently promises the reader “this function has real branching logic depending on whether you provide this.” A default parameter silently promises “I’ve already made a reasonable choice for you, override it if you need to.” Those are genuinely different promises, and a signature that gets the choice backwards — or, worse, uses ? everywhere out of habit regardless of which promise actually applies — costs every future reader a trip into the implementation to figure out what should have been obvious from the signature alone. That’s the real, compounding cost of getting this wrong, far more than any single runtime bug: it’s the tax every future reader of your framework pays, one confused pause at a time, for a decision that took you five extra seconds to make correctly the first time.
Optional Chaining Is Not Optional Parameters: A Confusion Worth Killing Early
I’ve sat in enough mock interviews and pair-programming sessions to know this confusion is genuinely common, especially among engineers who picked up TypeScript through scattered tutorials rather than a structured path: optional chaining (?.) and TypeScript optional parameters (param?:) share a question mark and absolutely nothing else about how they work, and conflating them leads to some genuinely strange code.
// Optional PARAMETER — declared in a function signature, controls what arguments are required at the call site
function getUserRole(user?: User): string {
return user?.role ?? 'guest'; // this second `?.` is optional CHAINING, a completely different feature
}
Optional chaining is a property-access operator that short-circuits to undefined if the object being accessed is null or undefined, rather than throwing a TypeError. It has nothing to do with function parameter declarations — you use it when reading a possibly-absent property off a possibly-absent object, most commonly right after you’ve received a value from an TypeScript optional parameter, an optional property, or an API response with genuinely uncertain shape. The confusion I actually see in the wild isn’t usually engineers mixing up the two concepts in the abstract — it’s engineers who understand optional chaining well and, because it “feels” related, start reaching for ?. defensively on values that were never actually optional to begin with, which is its own quiet code smell: sprinkling ?. everywhere as a reflex rather than because the type system is actually telling you a value might be absent is a sign you’ve stopped trusting your own type annotations, and it’s worth treating that instinct as a signal to go check whether a parameter further up the call chain should have been a default parameter instead of an optional one, closing off the uncertainty at the source rather than defending against it at every downstream read site.
Optional Parameters and Utility Types: Partial, Required, and Pick
TypeScript ships a small family of built-in utility types that transform optionality across an entire interface at once, and understanding how they relate to the per-parameter optionality covered throughout this article rounds out the picture, especially because these utility types show up constantly in well-designed test framework code, not just in application code.
interface TestEnvironmentConfig {
baseUrl: string;
apiKey: string;
timeoutMs: number;
retries: number;
}
// Every property becomes optional — perfect for an overrides-style parameter
function withConfig(overrides: Partial<TestEnvironmentConfig> = {}): TestEnvironmentConfig {
const defaults: TestEnvironmentConfig = { baseUrl: 'https://staging.example.com', apiKey: '', timeoutMs: 30000, retries: 2 };
return { ...defaults, ...overrides };
}
// The inverse — every property becomes required, useful for validating a fully-resolved config before a suite runs
function assertConfigComplete(config: Partial<TestEnvironmentConfig>): asserts config is Required<TestEnvironmentConfig> {
const requiredKeys: (keyof TestEnvironmentConfig)[] = ['baseUrl', 'apiKey', 'timeoutMs', 'retries'];
for (const key of requiredKeys) {
if (config[key] === undefined) {
throw new Error(`Missing required config value: ${key}`);
}
}
}
Partial<T> is doing structurally the same job at the type level that the ? TypeScript optional parameter marker does at the individual-parameter level, just applied across every property of an interface at once — which is exactly why it pairs so naturally with the destructured-options-parameter pattern covered earlier in this article; Partial<T> is very often the type annotation sitting right next to that pattern’s outer = {} default. Required<T> does the reverse, stripping every optional marker off every property, and it’s genuinely useful in the specific scenario shown above: a config object that starts life as a Partial, gets progressively filled in across a setup phase (environment variables, a config file, command-line overrides, each contributing some subset of fields), and needs a final validation step that both checks completeness at runtime and narrows the type for the compiler afterward using a type predicate function — the asserts config is Required<TestEnvironmentConfig> return annotation is what makes the compiler trust, after this function returns without throwing, that every property really is present for the rest of the code that follows.
The relationship worth internalizing: individual TypeScript optional parameters and default parameters are the tools you reach for at the level of a single function’s argument list. Partial, Required, and their sibling Pick/Omit are the tools you reach for when you need to describe a variation on an entire existing shape without redeclaring it from scratch. Good test framework code tends to use both together constantly — a base interface with some properties genuinely always-required and some genuinely always-optional, and utility types layered on top for the specific partial or complete views different parts of the framework need at different lifecycle stages.
Optional Parameters in Playwright’s Own Fixture System
If you’ve spent any real time extending Playwright’s test runner with custom fixtures, you’ve already been using a sophisticated, real-world application of everything in this article, whether or not you noticed it as such. Consider a typical authenticated-page fixture:
import { test as base } from '@playwright/test';
type AuthFixtures = {
authenticatedPage: Page;
testUser: { username: string; role: 'admin' | 'standard' };
};
export const test = base.extend<AuthFixtures>({
testUser: async ({}, use) => {
await use({ username: 'default-test-user', role: 'standard' });
},
authenticatedPage: async ({ page, testUser }, use) => {
await page.goto('/login');
await page.fill('#username', testUser.username);
await page.fill('#password', 'test-password-123');
await page.click('#login-button');
await use(page);
}
});
The testUser fixture here is functionally playing the role of a default parameter for every test file that uses authenticatedPage without explicitly overriding testUser itself — any test that just writes test('some scenario', async ({ authenticatedPage }) => {...}) silently gets the default standard user, while a test needing admin access can override the testUser fixture at the describe-block or test level using test.use({ testUser: { username: 'admin-user', role: 'admin' } }). It’s not literally the same mechanism as a function’s default parameter — fixtures are resolved through Playwright’s own dependency-injection-style test runner internals, not through JavaScript’s parameter-substitution semantics — but the design intent is identical to everything covered in this article: provide a sensible default that covers the overwhelming majority of test cases, and make overriding it, for the minority of cases that need something different, cheap and explicit. Recognizing that same intent recurring across a completely different mechanism (fixtures) reinforces why understanding the underlying design principle matters more than memorizing any single syntax — the principle travels; the syntax doesn’t.
Default Parameters and Abstract Classes: A Rule That Doesn’t Work the Way You’d Expect
A genuinely advanced edge case, but one that’s bitten me personally while designing a base Page Object hierarchy: default parameter values are not part of a method’s type signature as far as interface and abstract class implementation checking is concerned, which means a subclass overriding a method is under no obligation to preserve, or even acknowledge, the parent’s default value.
abstract class BasePage {
abstract navigate(path: string, timeoutMs?: number): Promise<void>;
}
class LoginPage extends BasePage {
async navigate(path: string, timeoutMs: number = 45000): Promise<void> {
// LoginPage happens to be slow to load in staging, so it overrides with a longer default
}
}
class DashboardPage extends BasePage {
async navigate(path: string, timeoutMs: number = 5000): Promise<void> {
// DashboardPage is fast, so it overrides with a shorter default
}
}
Both subclasses satisfy the abstract navigate signature perfectly fine — the abstract method only declares that timeoutMs is optional, it says nothing about what value should be substituted when it’s omitted, because default values genuinely live outside the type-level contract entirely; they’re a runtime substitution detail, not a type. This is powerful when it’s intentional, as in the example above where two page objects legitimately have different sensible defaults based on real-world load characteristics of the pages they represent. It’s a genuine trap when it’s accidental — I’ve seen a codebase where a base class’s timeoutMs default of 30000 got silently dropped to the language’s own implicit default handling in a subclass override that forgot to specify a default at all, turning what should have been a plain number parameter back into an effectively-required one at that specific subclass, with nothing in the type checker flagging the inconsistency because, again, defaults aren’t part of the checked contract. The practical takeaway: if a default value in a base class encodes a genuinely important behavioral decision, don’t rely on subclasses to inherit it implicitly — either make it explicit and re-declare it deliberately in every override with a comment explaining why it’s the same or different, or move the default-resolution logic into a shared, non-overridable helper method that every subclass calls into, so the actual value substitution happens in exactly one place regardless of how many classes extend the hierarchy.
A Longer Worked Example: Refactoring a Real Flaky-Test Retry Utility
To tie the entire article together, here’s a complete, warts-and-all before-and-after of a retry utility, the kind every test automation framework eventually grows, showing the reasoning at each decision point rather than just the final answer.
// BEFORE — the version that shipped under deadline pressure
async function retry(fn: Function, attempts?: number, wait?: number, shouldRetry?: Function): Promise<any> {
let lastError;
const maxAttempts = attempts || 3;
const waitMs = wait || 1000;
for (let i = 0; i < maxAttempts; i++) {
try {
return await fn();
} catch (err) {
lastError = err;
if (shouldRetry && !shouldRetry(err)) {
throw err;
}
await new Promise(r => setTimeout(r, waitMs));
}
}
throw lastError;
}
Take a moment to catalogue everything wrong with this before looking at the fix, because every single issue maps directly back to a concept from earlier in this article. fn: Function and shouldRetry?: Function use the near-useless built-in Function type, which tells the compiler almost nothing about the actual call signature or return type — a separate problem from this article’s core topic, but one that compounds the parameter issues, because with return types this loose, TypeScript can’t help catch misuse of the TypeScript optional parameters either. attempts || 3 and wait || 1000 use the logical OR operator instead of nullish coalescing, which means passing 0 explicitly for either — a legitimate, if unusual, value someone might pass to mean “don’t wait between retries” — gets silently overridden back to the default, because 0 is falsy in JavaScript’s boolean coercion, an entirely different bug class than anything covered so far in this article but one that stems from the exact same root cause: treating “omitted” and “falsy” as the same condition when they are not. And every one of the four parameters is marked optional with ? even though three of them have obvious, confident defaults that should never have been left as ad-hoc runtime fallbacks in the first place.
// AFTER — applying every principle from this article
interface RetryOptions<T> {
maxAttempts?: number;
waitMs?: number;
shouldRetry?: (error: unknown) => boolean;
}
async function retry<T>(
fn: () => Promise<T>,
{ maxAttempts = 3, waitMs = 1000, shouldRetry = () => true }: RetryOptions<T> = {}
): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
if (!shouldRetry(err)) {
throw err;
}
if (attempt < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, waitMs));
}
}
}
throw lastError;
}
// call sites
await retry(() => apiClient.get('/orders'));
await retry(() => apiClient.get('/orders'), { maxAttempts: 5, waitMs: 500 });
await retry(() => apiClient.get('/orders'), { shouldRetry: err => err instanceof NetworkError });
fn is now properly typed as a generic () => Promise<T>, which lets the whole utility’s return type flow through correctly to every call site instead of degrading to any. All three configuration values moved into a destructured options object with individual defaults and an outer = {} fallback, because there are three independently-meaningful optional values, exactly the threshold discussed earlier where positional TypeScript optional parameters stop being the right call. Every default is a genuine default parameter, not a manually-checked optional one, because all three — attempt count, wait duration, and the always-retry predicate — have confident, sensible baseline values with no meaningful “explicit absence” semantics to preserve. And the fallback logic switched from || to relying on default-parameter substitution directly (which, as covered in the FAQ, only triggers on undefined, not on falsy values generally), fixing the waitMs: 0 bug entirely as a side effect of using the right language feature rather than patching the symptom with a manual ?? swap. Nothing about this refactor required exotic TypeScript knowledge — every single change is a direct, mechanical application of the decision framework laid out at the start of this article, which is exactly the point: understanding optional and default parameters properly doesn’t just help you avoid writing bugs, it gives you a fast, reliable diagnostic checklist for finding and fixing them in code you didn’t write, under time pressure, which is the actual day-to-day reality of maintaining a test automation framework that other people depend on.
Final Interview Round: Whiteboard-Style Prompts
A last batch of prompts, closer to what you’d actually be asked to work through live on a call or a whiteboard rather than answer verbally, because live coding rounds on this exact topic are common enough in SDET and automation architect interviews that it’s worth rehearsing the motions, not just the theory.
“Design a function signature for a Playwright screenshot helper that needs a required selector, an optional filename (auto-generated if omitted), a default image format of PNG, and an optional flag to mask sensitive fields.”
A strong solution reaches for the destructured-options pattern immediately, given four independently-omittable concerns, rather than trying to force it into positional parameters:
interface ScreenshotOptions {
filename?: string;
format?: 'png' | 'jpeg';
maskSelectors?: string[];
}
async function captureScreenshot(page: Page, selector: string, options: ScreenshotOptions = {}): Promise<string> {
const { filename = `screenshot-${Date.now()}`, format = 'png', maskSelectors = [] } = options;
const element = page.locator(selector);
const maskLocators = maskSelectors.map(s => page.locator(s));
await element.screenshot({
path: `${filename}.${format}`,
type: format,
mask: maskLocators
});
return `${filename}.${format}`;
}
Notice filename‘s default is a default parameter — a genuinely computed, sensible fallback, using the exact “default expressions re-evaluate per call” behavior covered earlier to guarantee a fresh timestamp-based name on every invocation — while maskSelectors defaults to an empty array rather than being left truly optional and checked with a null guard everywhere it’s used, because an empty array is a completely honest, unambiguous representation of “nothing to mask” that every downstream .map() call can rely on without a guard.
“You’re given this broken function. Fix it: function createTestSuite(name, tags = [], timeout, retries = 2) {}“
The immediate compile error is timeout, a required parameter, appearing after tags, a default parameter — a direct violation of the ordering rule covered near the start of this article. The fix requires either reordering (moving timeout before tags) or, better, recognizing that a function accumulating this particular mix of required and optional concerns is again a good candidate for the destructured-options pattern, sidestepping the ordering rule entirely since object properties have no positional ordering constraint:
interface SuiteOptions {
tags?: string[];
timeoutMs: number;
retries?: number;
}
function createTestSuite(name: string, { tags = [], timeoutMs, retries = 2 }: SuiteOptions): void {}
A subtlety worth surfacing out loud in the interview: timeoutMs remains required inside the SuiteOptions interface, even though it now lives inside a destructured object alongside optional siblings — moving a parameter into an options object doesn’t automatically make it optional, and a strong candidate explicitly calls that out rather than silently marking every property optional out of habit, which is exactly the “using ? because it looks more flexible” mistake flagged repeatedly throughout this article.
Wrapping Up
Optional and default parameters are two of the smallest, most syntactically unremarkable features in the entire TypeScript language, and they’re also one of the highest-leverage places to practice genuine design judgment, precisely because the compiler lets you get the underlying intent wrong without ever raising an error. Every function you write from here forward is an opportunity to ask the one question this entire article has circled back to repeatedly: is the absence of this value something my function should have a confident opinion about, or something it genuinely needs to know about and handle explicitly? Answer that honestly, parameter by parameter, and the rest of the syntax — the ordering rules, the destructuring patterns, the interaction with strict null checks — falls into place as mechanical consequence rather than something you have to separately memorize. That’s the difference between having used optional parameters and actually understanding them, and it’s a difference that shows up in code review, in production incident postmortems, and in interview panels in roughly equal measure.
TypeScript Optional Parameters in BDD Step Definitions
For teams running Cucumber or a similar Gherkin-based BDD layer on top of a TypeScript automation framework, step definitions are another place where TypeScript optional parameter design gets tested constantly, because step definitions sit at the boundary between loosely-typed feature-file text and the strongly-typed framework underneath, and that boundary is exactly where sloppy optional-parameter design tends to leak out into flaky, hard-to-debug scenarios.
import { Given, When, Then } from '@cucumber/cucumber';
Given('I am logged in as {string} user', async function (this: CustomWorld, userType: string) {
await this.loginAs(userType);
});
When('I search for {string}', async function (this: CustomWorld, query: string) {
await this.searchPage.search(query);
});
async function performSearch(page: SearchPage, query: string, options: { exactMatch?: boolean; category?: string } = {}): Promise<void> {
const { exactMatch = false, category } = options;
await page.enterQuery(query);
if (category) {
await page.selectCategory(category);
}
await page.submitSearch({ exactMatch });
}
The interesting design tension in BDD step definitions specifically is that Gherkin step text itself doesn’t have a native concept of “optional” the way a TypeScript function signature does — every value in a Gherkin step comes from matched text in the feature file, which means the optionality has to be pushed down into the underlying helper functions the step definitions call into, exactly like the performSearch helper above. I’ve seen teams try to solve this by writing near-duplicate Gherkin steps for every combination of optional behavior — When I search for "shoes" alongside When I search for "shoes" with an exact match alongside When I search for "shoes" in category "footwear" — which explodes the step definition file into dozens of thin wrappers, each one just forwarding a different combination of arguments into the same underlying performSearch helper. That’s not actually a bad pattern, to be clear — Gherkin’s whole value proposition is readable business-facing scenarios, and readable scenario text sometimes genuinely does require multiple distinct step phrasings rather than one step with hidden optional parameters the business reader can’t see. But it does mean the underlying helper function design matters even more in a BDD-heavy framework than in a framework where tests call helper functions directly, because that one shared helper is absorbing all the optional-parameter complexity that the Gherkin layer above it is deliberately hiding for readability.
Environment-Driven Defaults: A Pattern That Deserves More Scrutiny Than It Gets
A specific variant of default parameters shows up constantly in test framework configuration code, and it’s worth calling out because it introduces a wrinkle the earlier sections didn’t cover: defaults sourced from environment variables rather than literal values or simple expressions.
function createApiClient(
baseUrl: string = process.env.TEST_BASE_URL ?? 'https://staging.example.com',
timeoutMs: number = Number(process.env.TEST_TIMEOUT_MS) || 30000
): ApiTestClient {
return new ApiTestClient(baseUrl, timeoutMs);
}
This compiles fine and works, but it deserves more scrutiny than most engineers give it, for a reason directly tied to the “evaluated at call time, not definition time” behavior covered earlier in this article. Because process.env.TEST_BASE_URL is read fresh inside the default expression on every single call where baseUrl is omitted, this function is quietly sensitive to when it’s called relative to when environment variables get set — if something in your test setup sequence sets TEST_BASE_URL after some test files have already imported and started calling createApiClient, you can end up with different tests in the same run silently pointed at different base URLs, and because the default expression re-evaluates rather than being cached, the bug is genuinely intermittent and load-order-dependent rather than consistently reproducible, which makes it miserable to track down. I’ve hit a version of exactly this in a CI pipeline where a .env file was loaded via a setup hook that ran after the first handful of test files had already been statically imported (and, critically, had already called a module-level version of this kind of client factory as part of their own import-time setup) — most tests got the correct environment-driven URL, a handful of tests that happened to be structured slightly differently in their import order got the hardcoded fallback instead, and the resulting failure pattern looked for all the world like genuine environment flakiness rather than what it actually was, a parameter-default evaluation-order bug. The safer version of this pattern resolves environment-driven configuration once, into a plain constant, outside of any function’s parameter list entirely, and then uses that resolved constant as an unambiguous, non-reevaluating default:
const DEFAULT_BASE_URL = process.env.TEST_BASE_URL ?? 'https://staging.example.com';
const DEFAULT_TIMEOUT_MS = Number(process.env.TEST_TIMEOUT_MS) || 30000;
function createApiClient(baseUrl: string = DEFAULT_BASE_URL, timeoutMs: number = DEFAULT_TIMEOUT_MS): ApiTestClient {
return new ApiTestClient(baseUrl, timeoutMs);
}
Now the environment variable is read exactly once, at module load time, and every subsequent call to createApiClient without explicit arguments gets the identical, stable default — the default parameter is still doing real, useful work (letting individual tests override the client’s configuration when needed), but it’s no longer secretly coupling the function’s behavior to when, and how many times, it happens to be called relative to environment setup.
The tsconfig.json Settings That Actually Matter for This Topic
Since strictNullChecks has come up repeatedly as the single highest-leverage setting for making optional-parameter mistakes visible at compile time, it’s worth a proper, standalone rundown of the specific configuration I’d recommend for any test automation framework’s tsconfig.json, rather than leaving it as a passing mention.
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}
strict: true is a bundle flag that turns on strictNullChecks along with several sibling flags (noImplicitAny, strictFunctionTypes, and others), and it’s the setting I’d consider the actual floor for any codebase written after roughly 2019 — there’s very little excuse for a greenfield test automation framework to run without it, and the cost of retrofitting it onto a large existing codebase, while real, only grows the longer it’s postponed.
exactOptionalPropertyTypes is the one worth calling special attention to here because it directly tightens a gap this article has touched on more than once: without it, TypeScript treats an optional property (timeout?: number) as equivalent to a property whose type explicitly includes undefined (timeout: number | undefined), which means code is technically allowed to explicitly assign undefined to that property, not just omit it — { timeout: undefined } passes type checking identically to simply not including timeout at all. For most application code this distinction rarely matters, but for the exact API-payload and serialization scenarios covered earlier in this article, where “the property was omitted” and “the property was explicitly set to undefined” genuinely diverge once JSON.stringify and real backend APIs get involved, exactOptionalPropertyTypes closes that gap at the type level, requiring code to either omit an optional property entirely or assign it a value of its actual declared type — no more silently-allowed explicit undefined assignment muddying the distinction the rest of your validation logic is trying to rely on.
noUncheckedIndexedAccess is a more general-purpose strictness flag, less specific to this article’s core topic, but it interacts with optional-parameter-adjacent code constantly in test frameworks that do a lot of dynamic lookups — environment variable maps, test-data dictionaries keyed by scenario name, that sort of thing — by forcing every indexed access (someRecord[key]) to be typed as possibly undefined, rather than trusting the index signature’s declared value type unconditionally. I mention it here because in practice, tightening this flag frequently surfaces exactly the same class of latent optional-value bug this entire article is about, just at a slightly different syntactic location than a function parameter.
Glossary
A short reference glossary for terms used throughout this article, useful both as a review tool and as a quick vocabulary check before a technical interview on this topic.
- TypeScript optional parameter — a function parameter marked with
?that can be omitted at the call site; typed internally as a union includingundefined. - Default parameter — a function parameter assigned a fallback value (
= expression) that substitutes automatically when the argument is omitted or explicitlyundefined; typed internally withoutundefinedin its union. - Optional property — a property on an interface or type marked with
?that can be omitted when an object of that shape is constructed. - Optional chaining — the
?.operator, used to safely read a property or call a method on a value that might benullorundefined, unrelated in mechanism to TypeScript optional parameters despite the shared question mark. - Nullish coalescing — the
??operator, providing a fallback value specifically when the left-hand expression evaluates tonullorundefined(and not for other falsy values like0or an empty string, unlike the logical OR operator||). - Rest parameter — a parameter prefixed with
...that collects any number of remaining positional arguments into an array; must be the final parameter in the list. strictNullChecks— a TypeScript compiler flag that forces explicit handling ofnullandundefinedwherever a type doesn’t explicitly include them, turning a large class of optional-parameter misuse into a compile-time error instead of a runtime bug.Partial<T>— a built-in utility type that makes every property ofToptional.Required<T>— a built-in utility type that makes every property ofTrequired, stripping any optional markers.- Function overload — a mechanism for declaring multiple distinct call signatures for a single implemented function, used when the relationship between parameters can’t be captured cleanly by optional parameters alone.
Further Reading
For engineers who want to go beyond this article’s test-automation framing and dig into the language-level specification and broader ecosystem conventions, a few sources are worth bookmarking directly rather than relying on secondhand summaries. The TypeScript Handbook’s functions chapter remains the canonical reference for parameter syntax and function type compatibility rules. MDN’s default parameters page and its rest parameters page cover the underlying JavaScript runtime behavior both features are built on, independent of TypeScript’s added type checking. The tsconfig reference for the strict flag family is worth reading end to end at least once rather than just enabling strict: true blindly, since understanding what each individual flag under that umbrella actually changes makes it far easier to reason about compiler errors when they show up in unfamiliar shapes. And for the Playwright-specific examples throughout this article, the Playwright test fixtures documentation is the best source for seeing how a real, widely-used test framework applies the same default-and-override design philosophy this article has spent so much time on, at the level of an entire test runner rather than a single function.
One More Practical Checklist Before You Ship a New Helper Function
To close, here’s the concrete checklist I actually run through, mentally, every time I add a new parameter to a shared test automation helper — not as abstract theory, but as the literal sequence of questions I ask myself before opening a pull request.
- Does this parameter have one confident, sensible value that should apply in the overwhelming majority of calls? If yes, it’s a default parameter, full stop — resist the pull toward marking it optional just because that’s the more familiar-feeling syntax.
- Is the absence of this value genuinely, semantically different from any value I could plausibly default it to? If yes, it’s a true TypeScript optional parameter, and I need an explicit, visible line inside the function body that decides what happens in the absent case — not a silent assumption buried three branches deep.
- Am I about to add a third or fourth optional or default parameter to this function’s positional list? If yes, stop and convert to a destructured options object before writing another positional parameter, because every additional positional TypeScript optional parameter makes every future call site marginally harder to read correctly without checking the signature.
- Does my default value depend on anything that could plausibly change between calls — a timestamp, an environment variable, a randomly generated ID? If yes, make sure I actually want that re-evaluation behavior, and if I don’t, resolve it to a stable constant outside the function instead.
- Am I about to write
!to silence a compiler complaint about this parameter possibly beingundefined? If yes, stop — that’s almost always a sign the parameter should have had a default value instead of a suppressed type error. - If this parameter lives in a constructor, does its default represent a cheap, stateless value, or does it represent a dependency with real construction cost or shared state implications? If the latter, resolve it explicitly inside the constructor body with a true optional parameter, not a default parameter expression that re-constructs a fresh instance on every instantiation.
Run through those six questions honestly, every time, and the syntax choices this entire article has walked through in such detail stop being something you have to consciously recall — they become the automatic, unremarkable output of a design instinct you’ve actually built, which is the whole point of writing three-thousand-word articles about five-character pieces of syntax in the first place.
A Brief History: How Long Have These Features Actually Been Around
It’s worth situating optional and default parameters historically, because interview panels occasionally probe this, and because it clarifies which parts of what you’ve read in this article are TypeScript-specific type-system additions versus inherited JavaScript runtime behavior. Default parameters and rest parameters both arrived in ECMAScript 2015 (commonly called ES6), the same specification release that introduced let/const, arrow functions, classes, template literals, and destructuring — a genuinely foundational release for the language, and default parameters were part of that same wave of changes aimed at closing long-standing ergonomic gaps that JavaScript developers had been working around with manual patterns (the classic pre-ES6 idiom, function f(x) { x = x || 5; }, is exactly the pattern default parameters were introduced to replace, and if that idiom looks familiar, it’s the same falsy-versus-undefined trap covered earlier in the retry-utility refactor).
TypeScript’s TypeScript optional parameter syntax, using the ? marker, predates TypeScript’s adoption of native default parameter syntax by a meaningful stretch — TypeScript had TypeScript optional parameters as one of its earliest type-system features, present since close to the language’s first public releases, well before ECMAScript’s default parameters existed as a runtime feature for TypeScript to build type-checking on top of. This ordering matters for understanding why the two features, despite solving overlapping problems, evolved with genuinely distinct syntax and genuinely distinct typing behavior rather than being designed together as a unified pair from day one — TypeScript’s optional parameter marker was solving “how do I express that a JavaScript function tolerates fewer arguments than its parameter count suggests” in a language (early JavaScript) that had no formal way to express that intent at all beyond simply reading arguments.length inside the function body. Once ECMAScript 2015 gave JavaScript native default parameter syntax, TypeScript adopted and type-checked it as an additional, complementary mechanism rather than replacing the TypeScript optional parameter marker, which is exactly why modern TypeScript code has two overlapping-but-distinct tools available today, and exactly why choosing correctly between them is a skill worth this much dedicated attention rather than an arbitrary stylistic footnote.
“Config Object Disease”: Knowing When the Destructured Pattern Goes Too Far
Everything in this article has pushed fairly hard toward the destructured-options-object pattern once a function accumulates a few optional or default parameters, and I want to explicitly guard against over-applying that advice, because I’ve seen teams take “prefer options objects” as a blanket rule and end up with a different, equally real problem I’ve started calling config object disease in code reviews — a function that takes a single sprawling options object with fifteen optional properties, most of which are only relevant in narrow, mutually exclusive scenarios, effectively hiding a function that should have been three or four smaller functions behind one enormous, undifferentiated parameter bag.
// This is NOT a good use of the destructured pattern — it's config object disease
interface RunTestSuiteOptions {
parallel?: boolean;
workers?: number;
retries?: number;
shard?: { index: number; total: number };
reporter?: 'html' | 'json' | 'junit';
outputDir?: string;
grep?: string;
grepInvert?: string;
updateSnapshots?: boolean;
headed?: boolean;
debug?: boolean;
slowMo?: number;
video?: 'on' | 'off' | 'retain-on-failure';
trace?: 'on' | 'off' | 'retain-on-failure';
globalTimeout?: number;
}
function runTestSuite(options: RunTestSuiteOptions = {}): void { /* ... */ }
Every individual property here is defensible in isolation, and the ordering-rule problems that plague long positional parameter lists genuinely don’t apply to an options object — but the function as a whole has stopped communicating anything useful through its signature, because reading runTestSuite({ headed: true }) at a call site tells you nothing about which of the other fourteen properties matter for that scenario, whether any of them conflict with each other (does debug: true silently force headed: true and ignore workers? nobody can tell from the signature), or which subset represents a genuinely common configuration versus an obscure edge case nobody’s used in eighteen months. This is precisely the six-flag C# ClickElement method from the earlier migration war story, just wearing TypeScript’s more forgiving destructured-object clothing instead of C#’s positional-optional-parameter clothing — the underlying design failure, an options bag that’s grown without anyone asking whether every option genuinely belongs together, is identical regardless of which language’s syntax is hosting it.
The fix isn’t to abandon the destructured pattern — it’s to apply the same discipline to options objects that you’d apply to positional parameters: if a subset of your options only make sense together, and are mutually exclusive with another subset, that’s usually a sign you have two distinct functions (or, at minimum, two distinct, more narrowly-typed options interfaces) pretending to be one. In the example above, the actual fix that shipped, in the real framework this was drawn from, split execution options (parallel, workers, shard, retries) from debugging options (headed, debug, slowMo) into two genuinely separate entry points — a runTestSuite for CI use and a debugTestSuite for local interactive use — because in over a year of the original function’s existence, nobody had ever called it with both a debugging flag and a sharding flag set simultaneously, which was the tell that those two concerns never actually belonged in the same parameter list to begin with, destructured or not.
Domain-Specific Examples: Compliance-Heavy Test Automation
Given this blog’s audience skews toward BFSI, wealth management, healthcare, and payments testers — domains I’ve spent most of my own career in — it’s worth grounding this article’s principles in the specific flavor of test automation those domains demand, where audit trails, data masking, and regulatory assertion requirements add constraints that a generic e-commerce test suite simply doesn’t have to think about.
interface ComplianceAssertionOptions {
auditLogRequired?: boolean;
maskPii?: boolean;
regulatoryStandard?: 'PCI-DSS' | 'HIPAA' | 'SOX';
}
async function assertTransactionRecorded(
page: Page,
transactionId: string,
expectedAmount: number,
options: ComplianceAssertionOptions = {}
): Promise<void> {
const { auditLogRequired = true, maskPii = true, regulatoryStandard } = options;
const recordedAmount = await page.locator(`[data-testid="txn-${transactionId}-amount"]`).textContent();
expect(parseCurrency(recordedAmount)).toBeCloseTo(expectedAmount, 2);
if (auditLogRequired) {
const auditEntry = await fetchAuditLogEntry(transactionId);
expect(auditEntry).toBeDefined();
expect(auditEntry?.transactionId).toBe(transactionId);
}
if (maskPii) {
const pageContent = await page.content();
expect(pageContent).not.toMatch(/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/); // no raw card numbers rendered
}
if (regulatoryStandard === 'PCI-DSS') {
await assertPciMaskingCompliance(page);
}
}
Notice the deliberate choice here: auditLogRequired and maskPii are default parameters, both defaulting to true, because in a payments or healthcare testing context, the safe default is the strict one — a test author has to explicitly, visibly opt out of an audit-log check or PII masking verification ({ auditLogRequired: false }), rather than having to remember to opt in. This is a security- and compliance-conscious application of exactly the same “what’s the confident, sensible default” reasoning covered throughout this article, just applied in a domain where the cost of an accidentally-skipped compliance check is a genuinely serious business risk rather than a minor test-suite inconvenience, which makes the “default to the safe behavior, require explicit opt-out” pattern a much stronger default-versus-optional design instinct in these domains than it might be in a lower-stakes consumer product test suite. regulatoryStandard, on the other hand, is a true TypeScript optional parameter with no default at all, because there genuinely isn’t a safe or sensible default regulatory standard to assume — different transactions, different test suites, and different modules of the same platform may be subject to entirely different regulatory regimes, and silently assuming one would be actively misleading rather than merely inconvenient if wrong.
I’ve found that walking through examples like this one with junior engineers transitioning into regulated-industry test automation is one of the more effective ways to make the abstract “confident default versus meaningful absence” framework from earlier in this article click, because the stakes of getting it backwards are concrete and easy to explain in a single sentence: get maskPii‘s default wrong, in the wrong direction, and you’ve built a test framework that silently ships tests that would pass even if raw card data leaked into a rendered page, which is exactly the kind of gap an external auditor or a real security incident surfaces at the worst possible time, long after the original parameter design decision has been forgotten by everyone who made it.
Additional Interview Questions on Domain-Aware Design
“When designing a test assertion helper for a regulated industry, how do you decide which optional parameters should default to the strict behavior versus the permissive behavior?”
A strong answer articulates the asymmetry of failure cost directly: if the strict behavior is the default and a test author needs the permissive behavior for a legitimate reason, they have to write an explicit, visible override that a code reviewer will see and can question — a good friction point. If the permissive behavior is the default and strictness needs to be explicitly requested, the failure mode is silent: a test author who simply doesn’t know a stricter check exists, or forgets to request it, produces a passing test that provides false confidence about compliance behavior nobody actually verified. In domains where that false confidence carries real regulatory or security risk, defaulting to strict and requiring explicit, reviewable opt-out is close to a hard rule, not just a stylistic preference.
“Should regulatory-standard-specific behavior ever be a default parameter rather than a true optional one?”
Generally no, and the reasoning ties directly back to this article’s core decision framework: a default value is a statement that the function author has a confident, universally-applicable opinion about what should happen in the common case. Regulatory applicability is, by its nature, not universally applicable — it depends on jurisdiction, transaction type, and business context in ways a single function shouldn’t presume to guess at silently. The one narrow exception worth naming: if a test framework is genuinely scoped to a single regulatory context for its entire lifetime — a healthcare-only test suite that will never test a PCI-DSS payment flow — hardcoding that context as a module-level constant rather than a per-call parameter at all is arguably a cleaner design than either a default or an TypeScript optional parameter, since the “choice” isn’t actually a choice being made per call in that scenario, it’s a fixed property of the entire framework.
A Note on Readability Over Cleverness
I want to close this already-long article with a point that’s less about mechanics and more about the judgment this whole topic ultimately serves: every technique covered here — default parameters, true TypeScript optional parameters, destructured options objects, generic defaults, overloads — exists to make a function signature communicate as much honest, accurate information as possible to the next person who reads it, without that person needing to open the implementation. It’s genuinely possible to use every single technique in this article correctly, in isolation, and still end up with a signature that’s harder to understand than a simpler, less “clever” alternative would have been, because you optimized for demonstrating mastery of the type system rather than for the actual reader standing in front of your code six months from now, under their own deadline pressure, trying to figure out what your function does without reading forty lines of implementation. The single best test I know for whether a signature has gotten this right: read it out loud, cold, to someone who’s never seen the function before, and see whether they can correctly guess what happens when they call it with just the required arguments. If they can’t, no amount of correctly-applied syntax from this article has actually solved the problem it exists to solve.
The Python Comparison: Why JavaScript’s Re-Evaluation Behavior Is Actually a Safety Feature
For engineers with any Python background — and I run into more than a few, since Python’s requests-plus-pytest ecosystem is a common alternative stack in QA orgs that haven’t standardized on TypeScript — there’s a specific, well-known Python footgun that TypeScript’s default parameter design happens to sidestep entirely, and understanding why is a genuinely useful way to appreciate a design decision covered earlier in this article that might otherwise seem like a minor implementation detail.
Python evaluates default argument values exactly once, at function definition time, not on every call — the opposite of the JavaScript and TypeScript behavior covered at length earlier in this article. This produces one of Python’s most infamous beginner traps when the default value is a mutable object:
# Python — the classic mutable default argument trap
def add_test_result(name, results=[]):
results.append(name)
return results
add_test_result('test_a') # ['test_a']
add_test_result('test_b') # ['test_a', 'test_b'] — SURPRISE, the same list object persisted
Because Python’s results=[] default is created exactly once when the function is defined, every call that omits results shares the exact same underlying list object, and mutations from one call silently leak into the next — a genuinely surprising, widely-documented gotcha that trips up even experienced Python engineers occasionally, and one that Python’s own style guides and linters specifically warn against. Now recall the timestamp example from earlier in this article, where TypeScript’s default parameter expressions re-evaluate fresh on every call specifically because JavaScript’s default parameters were designed, from the ECMAScript 2015 specification onward, to behave like an ordinary expression evaluated at call time rather than a value computed once and cached. This design choice, which earlier in this article I framed mainly as something to be aware of for computed defaults like Date.now(), turns out to structurally prevent the entire class of mutable-default-argument bug that plagues Python:
// TypeScript — the equivalent pattern is safe by construction
function addTestResult(name: string, results: string[] = []): string[] {
results.push(name);
return results;
}
addTestResult('test_a'); // ['test_a']
addTestResult('test_b'); // ['test_b'] — a fresh array every time, no shared state
Every call that omits results gets a brand-new, empty array, because the = [] expression is evaluated fresh on that specific call, exactly the same mechanism that gave a fresh timestamp on every call to the run-ID generator earlier in this article. It’s a genuinely nice example of how understanding a language feature’s underlying evaluation model, rather than memorizing “this is just how you write a default value,” pays off across seemingly unrelated scenarios — the same re-evaluation behavior that requires a moment of care around computed timestamps and environment variables (as covered in earlier sections) is precisely what makes mutable default values like empty arrays and empty objects safe to use without a second thought, something Python engineers specifically cannot take for granted and have to work around with an explicit None-then-initialize idiom instead.
Optional Parameters in Custom Playwright Reporters
A more specialized but genuinely common scenario for teams running mature TypeScript test frameworks: building a custom Playwright reporter to integrate results with an internal dashboard, a Slack notification pipeline, or a compliance audit trail. Reporter constructor options are another concrete, real-world site where this article’s principles get applied at the framework-configuration level rather than the individual-test-helper level.
import type { Reporter, TestCase, TestResult, FullConfig } from '@playwright/test/reporter';
interface CustomReporterOptions {
slackWebhookUrl?: string;
notifyOnlyOnFailure?: boolean;
minimumSeverity?: 'info' | 'warning' | 'critical';
}
class ComplianceReporter implements Reporter {
private readonly notifyOnlyOnFailure: boolean;
private readonly minimumSeverity: 'info' | 'warning' | 'critical';
constructor(options: CustomReporterOptions = {}) {
this.notifyOnlyOnFailure = options.notifyOnlyOnFailure ?? true;
this.minimumSeverity = options.minimumSeverity ?? 'warning';
// slackWebhookUrl deliberately has NO default — silently notifying nowhere is fine,
// but silently notifying the WRONG channel because of an assumed default URL is not
}
onTestEnd(test: TestCase, result: TestResult): void {
if (this.notifyOnlyOnFailure && result.status !== 'failed') return;
// ... dispatch notification logic
}
}
export default ComplianceReporter;
Playwright’s own configuration file passes reporter options as a plain object literal defined in playwright.config.ts, which means this constructor’s parameter design directly shapes what every engineer on the team sees and edits in that shared config file — a genuinely high-visibility spot for a badly-designed options interface to cause confusion. The choice to leave slackWebhookUrl with no default at all, rather than defaulting to an empty string or some placeholder value, is deliberate and mirrors the regulatoryStandard example from the compliance section earlier: an empty-string default would compile fine and might even avoid a runtime error if the reporter’s dispatch logic happened to no-op on an empty URL, but it would silently mask a misconfiguration (someone forgot to set the webhook URL in a new environment’s config) as if it were a deliberate choice to disable notifications, when the two situations genuinely deserve to be handled — and probably logged — differently.
More Common Mistakes, Continued
6. Treating a Boolean TypeScript Optional Parameter as a Substitute for a Proper Union Type
A pattern I flag constantly in review, especially in older codebases that predate a team’s adoption of stricter linting: a function that grows a second, then a third boolean TypeScript optional parameter, each one controlling a genuinely distinct mode of operation, rather than being consolidated into a single parameter typed as a proper string union.
// Before — boolean sprawl
function generateReport(data: TestData[], isJson?: boolean, isVerbose?: boolean, isSummaryOnly?: boolean): string {
// what happens if isJson AND isSummaryOnly are both true? isVerbose AND isSummaryOnly?
// the type signature can't tell you, and neither can the caller without reading the implementation
}
// After — a union type makes the actual, exclusive choices explicit
function generateReport(data: TestData[], format: 'text' | 'json' = 'text', detail: 'summary' | 'verbose' = 'summary'): string {
// every combination is now a real, intentional 2x2 matrix, not an accidental 2^3 combinatorial mess
}
Three independent optional booleans imply eight theoretically possible combinations, most of which were never intended to be meaningful and several of which probably produce silently wrong or undefined behavior if a caller happens to set an unintended combination — a direct, more severe version of the same “config object disease” problem covered in the destructured-options section earlier, except here it shows up even in a short, purely-positional parameter list. Reworking boolean flags into a small number of proper union-typed parameters, each with a genuine default, doesn’t just shrink the combinatorial surface area to something a caller can reason about — it also makes every legitimate combination self-documenting in the type signature itself, since format: 'json' reads unambiguously at a call site in a way isJson: true alongside two other unexplained booleans never quite does.
7. Forgetting That TypeScript’s Structural Typing Lets Extra Properties Slip Past an Optional-Property Interface in Some Contexts
A subtler mistake, but one worth knowing because it produces genuinely confusing “why didn’t the compiler catch this” moments: TypeScript’s structural typing means an object with extra, unexpected properties can sometimes be assigned to a variable or parameter typed with a narrower interface, especially when the object comes from a variable rather than an inline literal, because TypeScript’s stricter “excess property check” only fires reliably on object literals assigned directly, not on values passed through an intermediate variable.
interface RequestOptions {
timeoutMs?: number;
}
function makeRequest(options: RequestOptions = {}) { /* ... */ }
const configFromElsewhere = { timeoutMs: 5000, retries: 3, mode: 'strict' }; // has extra properties
makeRequest(configFromElsewhere); // compiles fine — no excess property error, because it's not an inline literal
makeRequest({ timeoutMs: 5000, retries: 3 }); // THIS would be a compile error — inline literal, excess property caught
This isn’t a bug in TypeScript so much as a deliberate, if occasionally surprising, tradeoff of structural typing — the type system generally cares whether an object has at least the required shape, not whether it has exactly that shape, and the stricter excess-property check on object literals exists as a convenience for catching likely typos (a property name that’s almost right but not quite) rather than as a comprehensive shape-matching guarantee everywhere. The practical relevance to this article: if you’re relying on a narrow, optional-property-heavy options interface to reject a caller passing unexpected extra fields, know that this protection is genuinely inconsistent depending on how the caller constructs the object, and don’t design a security- or correctness-critical check around the assumption that an interface with optional properties will reliably reject anything outside its declared shape — it often won’t, and for anything where that distinction actually matters (validating an external API payload, for instance, rather than an internal test helper’s configuration), a runtime validation library is the right tool, not the structural type system alone.
Extended FAQ, Continued
Can I make a parameter optional in a function type but required in its implementation, or vice versa?
No — an implementing function’s parameter list has to be compatible with every type it’s assigned to or checked against, and optionality is part of that compatibility check (with the specific narrowing/widening asymmetry across subclass overrides covered earlier in this article as the one nuanced exception). You cannot declare a type expecting an optional parameter and then implement it with that parameter marked required, because any caller relying on the type’s promise that the parameter is omittable would break at runtime.
Does marking a parameter optional affect the function’s .length property at runtime?
Yes, and this is a genuinely under-known piece of trivia that occasionally matters for framework-level code that introspects function arity. A function’s .length property (the built-in JavaScript property reporting how many parameters a function declares) counts only the parameters before the first one with a default value or the first TypeScript optional parameter — parameters after that point, and rest parameters, are excluded entirely from the count. function f(a: string, b?: number, c: string = 'x') {} has f.length === 1, not 3, because b‘s optionality effectively truncates what the runtime considers the function’s “required” arity for this specific introspection purpose. This occasionally surprises engineers writing generic higher-order test utilities that branch on a callback’s .length to decide how to invoke it.
Is there a TypeScript-native way to require “at least one of these TypeScript optional parameters” be provided?
Not directly through the plain optional parameter syntax covered in this article — but it’s achievable through a more advanced discriminated union or conditional type pattern applied to a destructured options object, essentially defining several valid “shapes” the options object can take and requiring the argument to match at least one of them. This is meaningfully more advanced than anything else covered here and, in my experience, rarely worth the added type complexity for internal test framework code — a runtime check with a clear thrown error, inside the function body, is usually more maintainable and more comprehensible to the next engineer than an elaborate union-of-required-shapes type designed purely to make the compiler enforce a “pick at least one” rule that a single runtime assertion communicates just as clearly.
The Actual Cost of Getting This Wrong, Quantified From Experience
I don’t have a controlled study to cite here — nobody runs a randomized trial on parameter design philosophy — but across a decade-plus of reviewing and maintaining test automation frameworks in regulated industries, the pattern has been consistent enough that I trust it as a genuine signal rather than a coincidence: functions with parameter signatures that violate the principles in this article — mixing optional and default parameters inconsistently, defaulting values that should have been explicit, letting positional TypeScript optional parameter lists sprawl past three or four — correlate directly with higher rates of “why did this test suddenly start failing after someone else’s unrelated change” incidents, because that’s precisely the failure mode a poorly-designed signature produces: a caller several files away, relying on an assumption the signature never actually promised, gets silently broken by a change that looked, in isolation, like a safe, backward-compatible addition. Every framework I’ve inherited that had accumulated this kind of signature debt took real, deliberate time to unwind — never a single dramatic rewrite, always a slow, deliberate one-function-at-a-time cleanup exactly like the retry-utility refactor and the C# migration war story covered earlier in this article. The lesson I keep relearning, and the one I’d most want a QA engineer three years into their career to take from this entire piece, is that the five extra seconds it takes to ask “is this a default or a true TypeScript optional parameter” is reliably one of the highest-return investments of attention available in day-to-day test automation work — cheaper by orders of magnitude than the debugging session it prevents, and invisible, in the best possible way, to everyone who never has to have that debugging session at all.
Myth-Busting: Claims About Optional and Default Parameters That Don’t Hold Up
Closing out with a handful of claims I’ve heard repeated confidently in code reviews, blog comments, and interview answers over the years, each one worth directly correcting because the confident-but-wrong version tends to spread faster than the accurate, more nuanced reality.
Myth: “Optional parameters and default parameters are basically interchangeable stylistic choices.”
This is the single most consequential myth this entire article has been arguing against, so it earns a direct restatement at the end: they produce different types inside the function body (T | undefined versus plain T), they interact differently with strictNullChecks, and choosing between them is a genuine design decision about whether absence is meaningful, not a coin flip between two equally valid syntaxes for the same idea. Treating them as interchangeable is exactly how the retry-utility bug, the mutable-default confusion, and the C# migration mess covered earlier in this article all originated.
Myth: “You should always use default parameters over TypeScript optional parameters because they’re ‘safer.'”
This overcorrects in the opposite direction. Defaults are safer specifically when a confident, universal fallback value genuinely exists — forcing a default onto a parameter where the absence is meaningful (the compliance regulatoryStandard example, the reporter’s slackWebhookUrl example, both covered earlier) doesn’t make the code safer, it papers over a distinction the function actually needs to preserve and hands the reader a false sense that every case has been thoughtfully handled when it hasn’t.
Myth: “Marking every parameter optional gives callers more flexibility, which is always a good thing.”
Flexibility at the type level is not free — every parameter you mark optional is a parameter every caller now has to consider might be absent, and every additional TypeScript optional parameter compounds the combinatorial space of behavior a reader has to hold in their head to predict what a given call actually does. The “config object disease” section earlier in this article is a direct rebuttal of this myth in practice: unconstrained flexibility, past a certain point, actively degrades a signature’s usefulness rather than improving it, because a signature that permits everything communicates almost nothing about what’s actually expected or common.
Myth: “TypeScript’s type checker will catch it if I get optional versus default wrong.”
It won’t, and this is arguably the most important myth to dispel, because it’s the one that gives engineers false confidence to skip the judgment call entirely. The compiler enforces syntactic rules — ordering, the required-after-optional restriction, type compatibility across overrides — but it has no way to know whether the semantic choice behind a given ? or = value reflects the actual intent of the function, because that intent lives entirely in a human’s understanding of the problem domain, not in anything expressible as a compile-time constraint. Every example of misuse covered throughout this article — the boolean sprawl, the environment-variable re-evaluation trap, the six-flag C# method, the falsy-versus-undefined retry bug — compiled without a single error. That’s not a flaw in the compiler; it’s a reminder that this entire topic sits squarely in the territory of design judgment the compiler was never going to check for you, which is exactly why an article this long was worth writing about a language feature this syntactically small.
A Condensed Summary for Skimmers
For anyone who jumped straight to the bottom, or is revisiting this article as a quick refresher before an interview, here’s the entire argument compressed into its essential form. TypeScript gives you two overlapping mechanisms for letting a caller omit a function argument: optional parameters (param?: Type), which leave the parameter typed as Type | undefined and require the function body to handle absence explicitly, and default parameters (param: Type = value), which substitute a real value automatically and leave the parameter typed as plain Type throughout the function body. Choose a default parameter whenever a confident, sensible fallback value genuinely exists; choose a true TypeScript optional parameter whenever the absence of a value is itself meaningful information the function needs to branch on. Required parameters must precede optional and default parameters in a parameter list, with a narrow exception for parameters typed with an explicit union including undefined. Default parameter expressions evaluate fresh on every call where the argument is omitted, not once at function definition — the opposite of Python’s behavior, and the reason JavaScript avoids Python’s mutable-default-argument trap by construction. Once a function accumulates more than two or three optional or default values, a destructured object parameter with an outer default is almost always more maintainable than a growing positional list — but don’t let that pattern sprawl into an undifferentiated options bag covering multiple, mutually-exclusive use cases either. Always enable strictNullChecks, because it’s the single setting that converts the most dangerous version of this article’s core mistake — treating an TypeScript optional parameter as if it were guaranteed present — from a silent runtime bug into a compile-time error you can’t accidentally ship.
Closing
None of this required exotic TypeScript knowledge, generics wizardry, or an advanced understanding of the compiler internals — everything in this article sits comfortably within syntax most engineers learn in their first month with the language. What separates a signature that ages well from one that quietly accumulates confusion and bugs over years of maintenance isn’t how much TypeScript you know; it’s whether you paused, for each individual parameter, to ask which of two genuinely different promises you were actually making to every future caller and reader of that function. That question is worth asking every single time, in every function you write, for the rest of your career writing test automation frameworks — and now, having read this far, you have no excuse left not to.
A Wealth Management Worked Example: Portfolio Rebalancing Assertions
The compliance section earlier in this article leaned on payments and healthcare scenarios, but wealth management testing — a domain I’ve spent a meaningful chunk of my own career in — has its own specific flavor of this problem, and it’s different enough from the payments example to be worth its own worked-through case, because the “sensible default” question gets genuinely harder when the values involved are financial thresholds rather than boolean flags.
interface RebalanceAssertionOptions {
toleranceBps?: number;
allowPartialFill?: boolean;
asOfDate?: Date;
}
async function assertPortfolioRebalanced(
page: Page,
portfolioId: string,
targetAllocations: Record<string, number>,
options: RebalanceAssertionOptions = {}
): Promise<void> {
const { toleranceBps = 25, allowPartialFill = false, asOfDate = new Date() } = options;
const actualAllocations = await fetchCurrentAllocations(portfolioId, asOfDate);
for (const [assetClass, targetPct] of Object.entries(targetAllocations)) {
const actualPct = actualAllocations[assetClass] ?? 0;
const driftBps = Math.abs(actualPct - targetPct) * 10000;
expect(driftBps, `${assetClass} drift exceeds tolerance`).toBeLessThanOrEqual(toleranceBps);
}
if (!allowPartialFill) {
const pendingOrders = await fetchPendingRebalanceOrders(portfolioId);
expect(pendingOrders.length, 'rebalance should be fully executed, not partially filled').toBe(0);
}
}
toleranceBps — tolerance expressed in basis points, a unit any wealth management engineer will recognize instantly — defaults to 25, and that number isn’t arbitrary: it’s a genuine default parameter because 25 basis points of drift tolerance is the platform’s actual documented rebalancing threshold, the same number product and compliance teams reference in their own specs, which means the test helper’s default isn’t just a programming convenience, it’s directly encoding a real business rule into the type-checked signature. This is worth sitting with for a moment, because it’s a slightly different flavor of “confident default” than the timeout and retry-count examples earlier in the article — those were engineering conventions the team settled on; this one is a number that exists independently in a compliance document somewhere, and the test helper’s default parameter is a deliberate, visible restatement of that external source of truth rather than an invented convenience value. That distinction matters practically: if the business’s documented drift tolerance ever changes, updating this one default parameter value is a one-line, highly visible change to a number every test author implicitly relies on — exactly the kind of “changing a default is a behavior change for every silent caller” scenario flagged in the FAQ section earlier in this article, except here the stakes of getting that change wrong or missing it are a genuinely material compliance question, not just a flaky test.
allowPartialFill defaults to false for the same safety-first reasoning covered in the earlier compliance section — a rebalance that’s only partially executed is the exception, not the norm, so tests should fail loudly by default unless a test author explicitly acknowledges and opts into the partial-fill scenario. asOfDate, on the other hand, defaults to new Date() — the current moment — which ties directly back to the “default expressions evaluate fresh on every call” behavior covered early in this article; every invocation without an explicit date gets genuinely “now,” which is exactly right for a live assertion against current portfolio state, but would be exactly wrong if this helper were ever reused inside a historical backtesting suite that needs to assert allocations as of a specific past date — a good reminder that even a well-reasoned default carries an implicit assumption about how the function will be used, and that assumption is worth stating out loud (in a doc comment, at minimum) rather than leaving implicit in a default value nobody examining the call site would think to question.
Optional and Default Parameters in Published Declaration Files
If you’ve ever published a shared test utility package internally — an npm package your organization’s multiple test repos all depend on, which is a natural evolution once a framework matures past a single codebase — optional and default parameters take on a dimension this article hasn’t touched yet: how they surface in the compiled .d.ts declaration file that’s actually what consumers of your package see and get autocomplete from, since consumers never see your source, only the compiled output.
// source: src/retry.ts
export async function retry<T>(
fn: () => Promise<T>,
{ maxAttempts = 3, waitMs = 1000 }: RetryOptions<T> = {}
): Promise<T> { /* ... */ }
// compiled: dist/retry.d.ts (what consumers actually import types from)
export declare function retry<T>(fn: () => Promise<T>, options?: RetryOptions<T>): Promise<T>;
Notice something the TypeScript compiler does here that’s easy to miss if you’ve never actually inspected a generated .d.ts file: the default values themselves — maxAttempts = 3, waitMs = 1000 — are stripped out entirely from the emitted declaration. The destructured parameter with its individual property defaults collapses down to a single, plain options?: RetryOptions<T> in the public type signature, because default values are runtime substitution logic, not type information, and a .d.ts file only ships type information — the actual JavaScript implementation, compiled separately into dist/retry.js, is where the default substitution logic actually lives. This is a direct, concrete consequence of the same principle covered earlier in the abstract-class section — defaults aren’t part of the type contract — except here it has a very practical implication for anyone publishing a package: a consumer hovering over retry in their editor sees “options is optional,” but has no way to discover from the type signature alone what maxAttempts actually defaults to without either reading your source code directly or checking your documentation, because the type system genuinely doesn’t carry that information across the package boundary.
The practical takeaway for anyone maintaining a shared test framework package consumed by other teams: default values need to be documented explicitly, in comments or a README, precisely because they’re invisible at the type level to anyone who only has your compiled declarations — the exact opposite of a required parameter’s type, which is always fully visible regardless of whether the consumer reads a single word of documentation. I’ve seen teams get bitten by this specifically during a framework version bump, where a shared retry utility’s default maxAttempts quietly changed from 3 to 5 between versions as part of what looked, from the changelog’s perspective, like a minor internal tuning change — no type signature changed, so nothing about the upgrade looked risky from a consumer’s side, but several downstream test suites that had structured their CI timeout budgets around the old default started timing out intermittently, and it took real investigation to trace the failure back to a default value change nobody had flagged as consumer-facing, because from a pure type-checking perspective, it wasn’t.
A Debugging Session, Start to Finish
To close, here’s a realistic, procedural walkthrough of exactly the kind of investigation this article’s principles are meant to shortcut — the actual sequence of steps I’d take diagnosing a flaky CI failure that traces back to an optional-versus-default confusion, written the way it really happens rather than as a tidy retrospective.
The report: a nightly regression suite has started failing intermittently on a single test, "should retry failed payment submission up to 3 times", roughly one run in six. The test itself hasn’t changed in months. First step, always: read the actual failure output rather than guessing.
FAIL tests/payment-submission.spec.ts ✕ should retry failed payment submission up to 3 times (2341ms) Expected mock to have been called 3 times, but it was called 1 time. at tests/payment-submission.spec.ts:47:34
Called once instead of three times — a loop that should be running three iterations is running one. That shape of failure, from everything covered in this article, should immediately point toward a retry-count parameter resolving to something other than the expected number, so the next step is finding the actual call site in the failing test, not the helper’s implementation yet.
// tests/payment-submission.spec.ts, line 44
const result = await retryPaymentSubmission(paymentPayload, {
maxAttempts: getConfiguredRetryLimit(), // returns a number... usually
});
Not a hardcoded value — a function call, getConfiguredRetryLimit(), feeding the options object. That’s worth chasing before looking at the retry helper itself, because if that function is the actual source of an intermittent undefined, everything downstream would behave exactly as observed regardless of how well-designed the retry helper’s own default parameter is.
// config/test-limits.ts
function getConfiguredRetryLimit(): number {
return Number(process.env.CI_RETRY_LIMIT); // no fallback if the env var is unset
}
There it is. process.env.CI_RETRY_LIMIT is only set in some CI runner configurations, not all of them — a detail that had drifted out of sync between two pipeline definitions during an unrelated infrastructure migration a few weeks prior, entirely unrelated to this test file. When the environment variable is unset, Number(undefined) evaluates to NaN, not undefined — a distinction worth pausing on, because it changes how the bug propagates. getConfiguredRetryLimit() doesn’t return undefined here; it returns the number NaN, which then gets passed explicitly as maxAttempts: NaN into the retry helper’s options object.
// the retry helper, as actually written in this codebase
async function retryPaymentSubmission(payload: PaymentPayload, { maxAttempts = 3 }: { maxAttempts?: number } = {}) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
// ...
}
}
And this is the piece that makes the bug genuinely subtle rather than obvious: the default parameter substitution mechanism covered throughout this entire article only triggers when the argument is undefined — as established explicitly in the FAQ section earlier — and NaN is not undefined. It’s a real, present, non-undefined numeric value that just happens to make every subsequent numeric comparison false. The well-designed default parameter here, exactly the pattern this article has recommended throughout, does not save you from this bug, because the bug isn’t actually about optional-versus-default parameter design at the retry helper’s boundary at all — it’s one layer upstream, where a function that should have had its own default parameter (getConfiguredRetryLimit returning a real fallback number instead of an ungated Number() coercion) was silently producing a value indistinguishable, at the type level, from a perfectly valid input.
The fix touches the actual root cause, not the retry helper, which was correct all along:
function getConfiguredRetryLimit(defaultLimit: number = 3): number {
const raw = process.env.CI_RETRY_LIMIT;
return raw ? Number(raw) : defaultLimit;
}
I’m including this full, unglamorous transcript — rather than just summarizing “it was an environment variable issue” — because it demonstrates something worth internalizing that no amount of “always use a default parameter for X, always use optional for Y” checklist fully captures on its own: applying this article’s principles correctly at one function boundary doesn’t guarantee correctness across an entire call chain, and diagnosing a real production-adjacent bug means tracing the actual data flow, one layer at a time, rather than assuming the first function you look at is where the mistake lives. The retry helper’s design was exactly right, by every standard covered in this article. The bug was still real, still costly, and still took a genuine investigation to find — because NaN, unlike undefined, doesn’t announce itself to a default parameter at all, and that’s precisely the kind of edge case that only becomes fast to recognize once you’ve built the deeper mental model this entire article has been arguing for, rather than a shallow “just add = 3” pattern-match.
🔥 Continue Your Learning Journey
Want to go beyond Playwright with Typescript setup and crack interviews faster? Check these hand-picked guides:
👉 🚀 Master TestNG Framework (Enterprise Level)
Build scalable automation frameworks with CI/CD, parallel execution, and real-world architecture
➡️ Read: TestNG Automation Framework – Complete Architect Guide
👉 🧠 Learn Cucumber (BDD from Scratch to Advanced)
Understand Gherkin, step definitions, and real-world BDD framework design
➡️ Read: Cucumber Automation Framework – Beginner to Advanced Guide
👉 🔐 API Authentication Made Simple
Master JWT, OAuth, Bearer Tokens with real API testing examples
➡️ Read: Ultimate API Authentication Guide
👉 ⚡ Crack Playwright Interviews (2026 Ready)
Top real interview questions with answers and scenarios
➡️ Read: Playwright Interview Questions Guide