The short answer
Put a @type with the key and value types on the declaration of an empty Map or Set:
/**
* @typedef {object} Product
* @property {string} sku
* @property {number} price
*/
/** @type {Map<string, Product>} */
const bySku = new Map();
/** @type {Set<string>} */
const discontinued = new Set();
/** @param {Product} product */
export function addProduct(product) {
bySku.set(product.sku, product);
}
/**
* @param {string} sku
* @returns {number | undefined}
*/
export function priceOf(sku) {
if (discontinued.has(sku)) return undefined;
return bySku.get(sku)?.price;
}
const restock = new Map([
["LAMP-01", 4],
["DESK-02", 1],
]);
for (const [sku, quantity] of restock) console.log(sku.toLowerCase(), quantity.toFixed(0));interface Product {
sku: string;
price: number;
}
const bySku = new Map<string, Product>();
const discontinued = new Set<string>();
export function addProduct(product: Product): void {
bySku.set(product.sku, product);
}
export function priceOf(sku: string): number | undefined {
if (discontinued.has(sku)) return undefined;
return bySku.get(sku)?.price;
}
const restock = new Map([
["LAMP-01", 4],
["DESK-02", 1],
]);
for (const [sku, quantity] of restock) console.log(sku.toLowerCase(), quantity.toFixed(0));jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["*.js"]
} bySku.set now accepts only a string and a Product, and bySku.get(sku) returns Product | undefined, which the optional chaining in priceOf handles. The restock Map needs no JSDoc, because its initial entries say what it holds.
Why it works
Map<K, V> and Set<T> are generic classes in TypeScript’s library. A @type on the variable fixes K and V, and every method is checked against them. get returns V | undefined because the key may be missing, and has does not change that: if (bySku.has(sku)) bySku.get(sku) is still possibly undefined to tsc.
The same thing in TypeScript
The TypeScript equivalent tab passes type arguments to the constructor, new Map<string, Product>(). JavaScript has no place for type arguments at a call, so the type goes on the variable instead.
What goes wrong
With the types in place, a key of the wrong type fails (TS2345), and reading a property straight off get() fails because the result may be undefined (TS2532):
/** @typedef {{ sku: string, price: number }} Product */
/** @type {Map<string, Product>} */
const bySku = new Map();
bySku.set(1001, { sku: "LAMP-01", price: 3900 });error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.
console.log(bySku.get("LAMP-01").price);error TS2532: Object is possibly 'undefined'.jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["*.js"]
} Without the @type, an empty new Map() holds any, and none of this is reported, even under strict:
const bySku = new Map();
bySku.set(1001, "not a product");
const product = bySku.get("LAMP-01");
console.log(product.sku.toFixed(2));jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["*.js"]
} Gotchas
has()does not narrowget(). Read once and check the result instead:const product = bySku.get(sku); if (product) ….- An empty
new Set()isSet<any>too. It accepts numbers and strings side by side without a word; give it a@typelike the Map. - Object keys are compared by identity. A
Map<{ id: string }, …>finds an entry only with the same object, not with an equal one; the types cannot help with that.
Questions
When does tsc infer the types of a Map by itself?
When the constructor gets initial entries: new Map([["LAMP-01", 4]]) is a Map<string, number>. An empty new Map() has nothing to infer from, so in a .js file it is Map<any, any>.
Map or plain object for a lookup table?
A Map keeps insertion order, allows any key type and has a cheap size; a plain object typed with Record is easier to write as a literal and to serialize. Both are fully typed.
Sources
- Map · MDN Web Docs
- Set · MDN Web Docs
- JSDoc Reference (@type) · TypeScript handbook
Last reviewed 21 October 2026.