The short answer
Write one JSDoc comment per call signature, each starting with @overload, above the function. The comment right above the function types the implementation:
/** @typedef {{ sku: string, name: string, price: number }} Product */
/** @type {Map<string, Product>} */
const catalog = new Map([["LAMP-01", { sku: "LAMP-01", name: "Desk lamp", price: 3900 }]]);
/**
* @overload
* @param {string} sku
* @returns {Product | undefined}
*/
/**
* @overload
* @param {string[]} skus
* @returns {Product[]}
*/
/**
* @param {string | string[]} skuOrSkus
* @returns {Product | undefined | Product[]}
*/
export function findProducts(skuOrSkus) {
if (typeof skuOrSkus === "string") return catalog.get(skuOrSkus);
return skuOrSkus.flatMap((sku) => catalog.get(sku) ?? []);
}
const lamp = findProducts("LAMP-01");
const basket = findProducts(["LAMP-01", "DESK-02"]);
console.log(lamp?.name, basket.length);type Product = { sku: string; name: string; price: number };
const catalog = new Map<string, Product>([["LAMP-01", { sku: "LAMP-01", name: "Desk lamp", price: 3900 }]]);
export function findProducts(sku: string): Product | undefined;
export function findProducts(skus: string[]): Product[];
export function findProducts(skuOrSkus: string | string[]): Product | undefined | Product[] {
if (typeof skuOrSkus === "string") return catalog.get(skuOrSkus);
return skuOrSkus.flatMap((sku) => catalog.get(sku) ?? []);
}
const lamp = findProducts("LAMP-01");
const basket = findProducts(["LAMP-01", "DESK-02"]);
console.log(lamp?.name, basket.length);jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["*.js"]
} findProducts("LAMP-01") now returns Product | undefined and findProducts([...]) returns Product[], so basket.length needs no check.
Why it works
TypeScript 5.0 added @overload to the JSDoc it reads. Each @overload comment becomes one call signature, exactly like the bodiless function findProducts(...): ...; lines in a .ts file. When you call the function, tsc tries the signatures in order and uses the first one the arguments fit; the return type comes from that signature.
The implementation’s own JSDoc (string | string[] in, the union out) must be broad enough to cover every overload, and callers never see it.
The same thing in TypeScript
The TypeScript equivalent tab has the classic form: two declarations without bodies, then the implementation. The JavaScript version is the same three signatures, written as comments.
What goes wrong
A call that fits no signature reports every overload it tried (TS2769):
/** @typedef {{ sku: string, name: string }} Product */
/**
* @overload
* @param {string} sku
* @returns {Product | undefined}
*/
/**
* @overload
* @param {string[]} skus
* @returns {Product[]}
*/
/**
* @param {string | string[]} skuOrSkus
* @returns {Product | undefined | Product[]}
*/
export function findProducts(skuOrSkus) {
return typeof skuOrSkus === "string" ? undefined : [];
}
findProducts(1001);error TS2769: No overload matches this call.
Overload 1 of 2, '(sku: string): Product | undefined', gave the following error.
Argument of type 'number' is not assignable to parameter of type 'string'.
Overload 2 of 2, '(skus: string[]): Product[]', gave the following error.
Argument of type 'number' is not assignable to parameter of type 'string[]'.jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["*.js"]
} Without overloads, the function has one signature with a union return type, and every caller pays for it. The array call gets Product | undefined | Product[], so reading .length fails twice, once for undefined (TS18048) and once because a single Product has no length:
/** @typedef {{ sku: string, name: string }} Product */
/**
* @param {string | string[]} skuOrSkus
* @returns {Product | undefined | Product[]}
*/
export function findProducts(skuOrSkus) {
return typeof skuOrSkus === "string" ? undefined : [];
}
const basket = findProducts(["LAMP-01", "DESK-02"]);
console.log(basket.length);error TS18048: 'basket' is possibly 'undefined'.error TS2339: Property 'length' does not exist on type 'Product | Product[]'.
Property 'length' does not exist on type 'Product'.jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["*.js"]
} Gotchas
- Order matters. tsc picks the first overload that fits, so put the narrow signatures before the wide ones.
- One comment per signature reads best. The release notes put each
@overloadin its own/** */block, with its own@paramand@returns. Several@overloadtags in one comment work too, but are harder to scan. - A generic can be simpler. When the return type follows the argument’s type directly (an array in, an array of the same thing out), a
@templatefunction avoids listing every case.
Questions
Which TypeScript version supports @overload?
TypeScript 5.0 added the @overload tag for JavaScript files. Older versions ignore it, so a project on 4.x needs another approach, such as two functions with different names.
Is the last JSDoc comment an overload too?
No. The comment directly above the function, without @overload, types the implementation. Callers only see the @overload signatures; the implementation's own signature is hidden from them.
Sources
- TypeScript 5.0 release notes (@overload support in JSDoc) · TypeScript handbook
- More on Functions (function overloads) · TypeScript handbook
Last reviewed 21 October 2026.