The short answer
Declare a type parameter with @template T and use T in the @param and @returns types. Put a constraint in braces before the name, and use keyof for a parameter that must be one of the object’s keys:
/**
* @template T
* @param {T[]} items
* @returns {T | undefined}
*/
export function last(items) {
return items[items.length - 1];
}
/**
* @template {{ id: string }} T
* @param {T[]} items
* @returns {Map<string, T>}
*/
export function indexById(items) {
return new Map(items.map((item) => [item.id, item]));
}
/**
* @template T
* @template {keyof T} K
* @param {T[]} items
* @param {K} key
* @returns {T[K][]}
*/
export function pluck(items, key) {
return items.map((item) => item[key]);
}
const orders = [
{ id: "A-1001", total: 40, status: "paid" },
{ id: "A-1002", total: 12, status: "open" },
];
const newest = last(orders);
const byId = indexById(orders);
const totals = pluck(orders, "total");
console.log(newest?.status, byId.get("A-1001")?.total, totals);export function last<T>(items: T[]): T | undefined {
return items[items.length - 1];
}
export function indexById<T extends { id: string }>(items: T[]): Map<string, T> {
return new Map(items.map((item) => [item.id, item]));
}
export function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
return items.map((item) => item[key]);
}
const orders = [
{ id: "A-1001", total: 40, status: "paid" },
{ id: "A-1002", total: 12, status: "open" },
];
const newest = last(orders);
const byId = indexById(orders);
const totals = pluck(orders, "total");
console.log(newest?.status, byId.get("A-1001")?.total, totals);jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["*.js"]
} last(orders) returns the order type itself, indexById returns a Map of orders, and pluck(orders, "total") returns number[], all inferred from the arguments.
Why it works
@template T is the JSDoc spelling of <T>. TypeScript infers T at each call from the arguments, the same way it does for a .ts function, so the helper keeps whatever type it is given.
@template {{ id: string }} TconstrainsT: it can be any object type with a stringid. Inside the function that is whyitem.idis allowed.@template {keyof T} Kdeclares a second parameter that must be one ofT’s keys.T[K]is the type of that property (indexed access), sopluckreturns the right element type for any key.
The same thing in TypeScript
The TypeScript equivalent tab writes the same functions with <T>, <T extends { id: string }> and <T, K extends keyof T>. In JSDoc the constraint goes in braces in front of the name; in TypeScript it follows extends.
What goes wrong
The constraints are checked at every call. Products have a sku, not an id, and "prize" is not one of their keys; both calls fail with TS2345:
/**
* @template {{ id: string }} T
* @param {T[]} items
* @returns {Map<string, T>}
*/
export function indexById(items) {
return new Map(items.map((item) => [item.id, item]));
}
/**
* @template T
* @template {keyof T} K
* @param {T[]} items
* @param {K} key
* @returns {T[K][]}
*/
export function pluck(items, key) {
return items.map((item) => item[key]);
}
const products = [{ sku: "LAMP-01", price: 3900 }];
indexById(products);error TS2345: Argument of type '{ sku: string; price: number; }[]' is not assignable to parameter of type '{ id: string; }[]'.
Property 'id' is missing in type '{ sku: string; price: number; }' but required in type '{ id: string; }'.
pluck(products, "prize");error TS2345: Argument of type '"prize"' is not assignable to parameter of type '"sku" | "price"'.jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["*.js"]
} Gotchas
- No type arguments at the call.
first<string>(x)in a.jsfile is two comparisons, not a type argument; let inference work, or annotate the value you pass. - One
@templateline can declare several names.@template T, Uworks, but a constraint applies to one name only, so constrained parameters get a line each. - Classes too.
@template Tabove a class makesTavailable in every member’s JSDoc, andnew Queue()infers it from the constructor’s arguments (see generic classes).
Questions
How do I pass a type argument explicitly, like last<string>(items)?
You cannot in a .js file: last<string>(items) is parsed as two comparisons. Let tsc infer T from the arguments, or type the argument itself (/** @type {string[]} */ ([])) so the inference has something to work with.
What is T when there is nothing to infer it from?
In a .js file a type parameter with nothing to infer from becomes any (in a .ts file it is unknown). Give it a default with @template [T=string] when that matters.
Sources
- JSDoc Reference (@template) · TypeScript handbook
- Generics · TypeScript handbook
Last reviewed 21 October 2026.