The short answer
Describe the options once with a @typedef whose properties are optional (names in brackets), mark the parameter itself optional, and destructure it with defaults:
/**
* @typedef {object} ExportOptions
* @property {"csv" | "json"} [format] Defaults to "csv".
* @property {string} [delimiter] Defaults to ",".
* @property {boolean} [header] Defaults to true.
*/
/**
* @param {{ sku: string, quantity: number }[]} rows
* @param {ExportOptions} [options]
* @returns {string}
*/
export function exportStock(rows, { format = "csv", delimiter = ",", header = true } = {}) {
if (format === "json") return JSON.stringify(rows);
const lines = rows.map((row) => `${row.sku}${delimiter}${row.quantity}`);
return (header ? [`sku${delimiter}quantity`, ...lines] : lines).join("\n");
}
const rows = [{ sku: "LAMP-01", quantity: 4 }];
exportStock(rows);
exportStock(rows, { delimiter: ";" });
exportStock(rows, { format: "json" });export interface ExportOptions {
format?: "csv" | "json";
delimiter?: string;
header?: boolean;
}
export function exportStock(
rows: { sku: string; quantity: number }[],
{ format = "csv", delimiter = ",", header = true }: ExportOptions = {},
): string {
if (format === "json") return JSON.stringify(rows);
const lines = rows.map((row) => `${row.sku}${delimiter}${row.quantity}`);
return (header ? [`sku${delimiter}quantity`, ...lines] : lines).join("\n");
}
const rows = [{ sku: "LAMP-01", quantity: 4 }];
exportStock(rows);
exportStock(rows, { delimiter: ";" });
exportStock(rows, { format: "json" });jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["*.js"]
} Every call passes some, all or none of the options, and inside the function format, delimiter and header have their full types with no undefined left in them.
Why it works
@property {string} [delimiter] is an optional property, like delimiter?: string. The destructuring pattern { format = "csv", delimiter = ",", header = true } = {} does two jobs: the = {} lets callers leave the whole object out, and each = value replaces a missing property. tsc follows both, so the variables it creates are "csv" | "json", string and boolean.
If you prefer the defaults in one object, merge them with spread syntax. Later properties win, so the caller’s options go last:
/**
* @typedef {object} ExportOptions
* @property {"csv" | "json"} [format]
* @property {string} [delimiter]
* @property {boolean} [header]
*/
/** @type {Required<ExportOptions>} */
const DEFAULTS = { format: "csv", delimiter: ",", header: true };
/**
* @param {ExportOptions} [options]
* @returns {Required<ExportOptions>}
*/
export function resolveOptions(options = {}) {
return { ...DEFAULTS, ...options };
}
const { delimiter } = resolveOptions({ delimiter: ";" });
console.log(delimiter.length);jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["*.js"]
} The same thing in TypeScript
The TypeScript equivalent tab of the first example uses an interface with format?: properties and puts the type after the pattern: { format = "csv", ... }: ExportOptions = {}. In JavaScript the type goes in the @param tag instead, and the pattern stays plain JavaScript.
What goes wrong
An object literal passed straight to the function is checked for extra properties, so a misspelled option is caught, with a suggestion (TS2561, a close relative of TS2353); a value outside the allowed strings is TS2322:
/**
* @typedef {object} ExportOptions
* @property {"csv" | "json"} [format]
* @property {string} [delimiter]
*/
/**
* @param {{ sku: string }[]} rows
* @param {ExportOptions} [options]
*/
export function exportStock(rows, { format = "csv", delimiter = "," } = {}) {
return format === "json" ? JSON.stringify(rows) : rows.map((row) => row.sku).join(delimiter);
}
exportStock([], { delimeter: ";" });error TS2561: Object literal may only specify known properties, but 'delimeter' does not exist in type 'ExportOptions'. Did you mean to write 'delimiter'?
exportStock([], { format: "xml" });error TS2322: Type '"xml"' is not assignable to type '"csv" | "json" | undefined'.jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["*.js"]
} The spread version has a gap. An optional property also accepts an explicit undefined, and { ...DEFAULTS, ...{ delimiter: undefined } } overwrites the default with undefined at runtime while the type still says string. With exactOptionalPropertyTypes on, tsc refuses that call:
/**
* @typedef {object} ExportOptions
* @property {string} [delimiter]
*/
/** @type {Required<ExportOptions>} */
const DEFAULTS = { delimiter: "," };
/** @param {ExportOptions} [options] */
export function resolveOptions(options = {}) {
return { ...DEFAULTS, ...options };
}
resolveOptions({ delimiter: undefined });error TS2379: Argument of type '{ delimiter: undefined; }' is not assignable to parameter of type 'ExportOptions' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
Types of property 'delimiter' are incompatible.
Type 'undefined' is not assignable to type 'string'.jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": [],
"exactOptionalPropertyTypes": true
},
"include": ["*.js"]
} Gotchas
- Only literals get the extra-property check. An options object built in a variable first is checked only for the keys it shares with the type. A misspelled key next to a correct one goes unreported; an object with no key in common is TS2559 (“has no properties in common”).
- Spreads are shallow. Nested option objects are replaced, not merged; merge them one level down yourself.
- Defaults replace
undefinedonly. JavaScript keeps anullthat a caller passes, so leavenullout of the option types unless the function handles it.
Questions
Should the defaults live in the parameter list or in a DEFAULTS object?
Destructuring with defaults in the parameter list is the shortest and keeps every default next to its name. A DEFAULTS object merged with spread syntax suits options you also need elsewhere, such as in a --help text or a test.
Why does the type say [format] when the function always has a format?
The type describes what callers pass, and callers may leave format out. Inside the function the default fills it in, so the destructured format is never undefined.
How do I write the type of the options after the defaults are applied?
Required<ExportOptions> makes every property required. The DEFAULTS object and the merged result in the second example both have that type.
Sources
- JSDoc Reference (@typedef and @property) · TypeScript handbook
- Destructuring assignment (default values) · MDN Web Docs
- TSConfig Reference (exactOptionalPropertyTypes) · TypeScript
Last reviewed 21 October 2026.