TypeScript Default Parameters

When I write TypeScript, I use default parameters all the time to make functions easier to call and less error‑prone. In this guide, I’ll walk through how default parameters work, where they shine, some common pitfalls, and a few patterns I use in real projects.

What are the default parameters in TypeScript?

A default parameter is just a function parameter that has a fallback value.

If the caller:

  • does not pass that argument at all, or
  • passes undefined explicitly

Then TypeScript (and JavaScript) uses the default value you defined.

This is handy when you want a parameter to be optional for the caller, but you still want a safe value at runtime instead of undefined.

Basic syntax and a simple example

Here’s the most basic form:

function greet(name: string, greeting: string = "Hello"): string {
return `${greeting}, ${name}!`;
}

console.log(greet("Alice")); // Hello, Alice!
console.log(greet("Bob", "Hi")); // Hi, Bob!
console.log(greet("Sam", undefined)); // Hello, Sam!

A few things to notice:

  • greeting has a default value “Hello”.
  • When I call greet(“Alice”), the default is used.
  • When I call greet(“Bob”, “Hi”), the passed value overrides the default.
  • Passing undefined also triggers the default.

This behavior (default applies on undefined) is important and we’ll come back to it.

Default parameters with multiple arguments

You can use defaults on any number of parameters, not just the last one.

Realistically, you’ll often see this around functions that wrap APIs or configuration options. For example, a simple pagination helper:

function fetchUsers(
page: number = 1,
pageSize: number = 20,
includeInactive: boolean = false
): void {
console.log(
`Fetching page ${page} with size ${pageSize}, includeInactive=${includeInactive}`
);
}

fetchUsers(); // page 1, size 20, includeInactive=false
fetchUsers(2); // page 2, size 20, includeInactive=false
fetchUsers(3, 50, true); // page 3, size 50, includeInactive=true

You can refer to the screenshot below to see the output.

TypeScript Default Parameters

Why this is nice:

  • Callers get sensible defaults most of the time: fetchUsers() is enough.
  • If a page needs a different page size or needs inactive users, they can override the defaults.

Another example you’ll commonly see in apps:

function createUser(
firstName: string,
lastName: string,
age: number = 30,
country: string = "USA"
): string {
return `Name: ${firstName} ${lastName}, Age: ${age}, Country: ${country}`;
}

console.log(createUser("John", "Doe"));
// Name: John Doe, Age: 30, Country: USA

console.log(createUser("Jane", "Smith", 25));
// Name: Jane Smith, Age: 25, Country: USA

console.log(createUser("Emily", "Johnson", 40, "Canada"));
// Name: Emily Johnson, Age: 40, Country: Canada

You can refer to the screenshot below to see the output.

Default Parameters TypeScript

Defaults keep the function signature readable and avoid a bunch of if checks inside the function body.

Default parameters vs optional parameters (?)

This is a common confusion: default parameters and “optional” parameters are not the same thing.

Optional parameter (?)

With an optional parameter, the caller can omit the argument, but there is no automatic runtime default.

function logMessage(message: string, prefix?: string): void {
console.log(prefix + message);
}

logMessage("System started"); // "undefinedSystem started"

Here:

  • prefix is typed as string | undefined.
  • If you call logMessage(“System started”), prefix is undefined.
  • TypeScript lets this compile, but at runtime you get “undefinedSystem started” unless you add your own checks.

Realistically, you’d write:

function logMessageSafe(message: string, prefix?: string): void {
const actualPrefix = prefix ?? "[INFO] ";
console.log(actualPrefix + message);
}

Default parameter

With a default parameter, you get a real runtime default:

function logMessageDefault(
message: string,
prefix: string = "[INFO] "
): void {
console.log(prefix + message);
}

logMessageDefault("System started"); // [INFO] System started
logMessageDefault("Disk full", "[WARN] "); // [WARN] Disk full

You can refer to the screenshot below to see the output.

Default Parameters in TypeScript

Here:

  • prefix is always a string at runtime.
  • You don’t need extra checks inside the function.

Rule of thumb I use:

  • If undefined is a meaningful value, I use an optional parameter (?).
  • If I always want a usable value, I use a default parameter.

How default parameters behave with undefined and null

One subtle but important detail: the default only kicks in when the argument is undefined or omitted.

function toUpper(text: string = "default"): void {
console.log(text.toUpperCase());
}

toUpper(); // DEFAULT
toUpper(undefined); // DEFAULT
// @ts-expect-error
toUpper(null); // runtime error
  • No argument → uses “default”.
  • undefined → uses “default”.
  • null → no default, and at runtime you’ll get an error because null.toUpperCase() is invalid.

So if there’s a chance null might appear, you still need to handle it explicitly.

Use default parameters with destructured objects

For anything with more than a couple of options, I prefer a single “options” object with defaults in the parameter list. It keeps the call site readable and the function flexible.

Let’s say we have a small HTTP wrapper:

interface RequestConfig {
url: string;
method?: string;
timeout?: number;
}

function fetchData({
url,
method = "GET",
timeout = 5000,
}: RequestConfig): void {
console.log(
`Fetching data from ${url} with method ${method} and timeout ${timeout}`
);
}

fetchData({ url: "https://api.example.com" });
// Fetching data from https://api.example.com with method GET and timeout 5000

fetchData({ url: "https://api.example.com", method: "POST" });
// Fetching data from https://api.example.com with method POST and timeout 5000

fetchData({ url: "https://api.example.com", timeout: 10000 });
// Fetching data from https://api.example.com with method GET and timeout 10000

Why I like this pattern:

  • The call site shows named arguments, which is easier to read.
  • Each property can have its own default (method = “GET”, timeout = 5000).
  • The RequestConfig interface stays simple and optional.

You can also go one level deeper with nested defaults, but I’d keep it readable:

interface RequestOptions {
url: string;
retryCount?: number;
headers?: {
Authorization?: string;
};
}

function request({
url,
retryCount = 3,
headers: { Authorization = "anonymous" } = {},
}: RequestOptions): void {
console.log(url, retryCount, Authorization);
}

request({ url: "https://api.example.com" });
// https://api.example.com 3 anonymous

request({
url: "https://api.example.com",
retryCount: 5,
headers: { Authorization: "Bearer token" },
});
// https://api.example.com 5 Bearer token

This is powerful, but if your parameter list starts to feel like a puzzle, it’s often better to keep the defaults simple and move more complex logic inside the function body.

Real‑world patterns I use default parameters for

Here are some patterns I actually use in projects.

Logging helpers

type LogLevel = "info" | "warn" | "error";

function log(message: string, level: LogLevel = "info"): void {
console.log(`[${level.toUpperCase()}] ${message}`);
}

log("User signed in");
// [INFO] User signed in

log("User not found", "warn");
// [WARN] User not found

This gives you a usable default level while still allowing overrides.

Feature flags

function isFeatureEnabled(
flagName: string,
defaultValue: boolean = false
): boolean {
// In a real app, you’d look this up from config, environment, or remote flags.
console.log(`Checking feature flag: ${flagName}`);
return defaultValue;
}

isFeatureEnabled("new-signup-flow"); // false
isFeatureEnabled("beta-dashboard", true); // true

I use this pattern when integrating with feature flag systems or configuration services.

Simple utility helpers

function padId(id: number, width: number = 5, char: string = "0"): string {
const str = id.toString();
if (str.length >= width) return str;
return char.repeat(width - str.length) + str;
}

padId(42); // "00042"
padId(42, 3); // "042"
padId(42, 4, " "); // " 42"

Callers can usually just call padId(42) and ignore the rest.

Common Issues and Best Practices

Over time, these are the main things I’ve learned to watch for.

1. Don’t overdo parameter defaults

If a function ends up with something like:

function process(
a: number = 1,
b: number = 2,
c: number = 3,
d: number = 4,
e: number = 5
) {
// ...
}

It’s probably doing too much.

When you see more than two or three parameters with defaults, consider switching to an options object:

interface ProcessOptions {
a?: number;
b?: number;
c?: number;
d?: number;
e?: number;
}

function process({
a = 1,
b = 2,
c = 3,
d = 4,
e = 5,
}: ProcessOptions) {
// ...
}

This scales better and is more self‑documenting at the call site.

2. Be careful with null vs undefined

As we saw earlier, defaults apply only on undefined, not null.

If there’s any chance a value comes from JSON or user input that might be null, you still need to guard against that inside the function.

3. Keep default expressions simple

Yes, you can do this:

function getConfig(config: Config = getDefaultConfigFromDisk()) {
// ...
}

But then you’re doing I/O in the parameter list, which can get confusing.

I prefer:

function getConfig(config?: Config) {
const actualConfig = config ?? getDefaultConfigFromDisk();
// ...
}

Short rule: keep defaults lightweight and predictable. Move heavier logic into the body.

4. Consider parameter order

In JavaScript/TypeScript you can have defaults before non‑defaults, but it can lead to awkward calls.

function example(a: number = 1, b: number) {
console.log(a, b);
}

You’d have to explicitly pass undefined to use the default for a and still provide b.