Recipe · Modules and packages

How do I type module.exports and require() in CommonJS JavaScript?

  • Node.js (CommonJS)
  • CommonJS
  • module.exports
  • require
  • ES modules
  • Checked with TypeScript 5.9

The problem My Node.js project still uses require() and module.exports. Does JSDoc type checking work there the way it does with import and export, and how do I share a @typedef between CommonJS files?

The short answer

Write CommonJS as usual: JSDoc on the functions, module.exports = { … } at the end. tsc reads the exported object as the module’s exports:

price.cjs price.cts TypeScript 5.9.3 TypeScript 5.9.3, run 21 October 2026: No errors. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. No errors. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. No errors. checked 21 October 2026
/**
 * @param {number} cents
 * @returns {string}
 */
function formatPrice(cents) {
  return `$${(cents / 100).toFixed(2)}`;
}

/** @typedef {{ sku: string, cents: number }} PriceTag */

/** @param {PriceTag} tag */
function describeTag(tag) {
  return `${tag.sku}: ${formatPrice(tag.cents)}`;
}

module.exports = { formatPrice, describeTag };
export function formatPrice(cents: number): string {
  return `$${(cents / 100).toFixed(2)}`;
}

export type PriceTag = { sku: string; cents: number };

export function describeTag(tag: PriceTag): string {
  return `${tag.sku}: ${formatPrice(tag.cents)}`;
}
A CommonJS module that exports with module.exports.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "types": ["node"]
  },
  "include": ["*.cjs"]
}

require() in another file then returns those functions with their types, and a @typedef in a CommonJS file is exported too, so import("./price.cjs").PriceTag names it:

main.cjs TypeScript 5.9.3 TypeScript 5.9.3, run 21 October 2026: No errors. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. No errors. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. No errors. checked 21 October 2026
const { formatPrice, describeTag } = require("./price.cjs");

/** @type {import("./price.cjs").PriceTag} */
const lamp = { sku: "LAMP-01", cents: 3900 };

console.log(formatPrice(1999), describeTag(lamp));
require() brings the functions in with their types; import() in a JSDoc type brings in the typedef.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "types": ["node"]
  },
  "include": ["*.cjs"]
}

Why it works

TypeScript understands the CommonJS patterns Node.js uses: module.exports = …, exports.name = … and const { … } = require("./file.cjs"). With @types/node installed, require is declared, and tsc resolves the path and types the result from the other file’s JSDoc. Every top-level @typedef of a module is part of its exports, whatever module syntax the file uses.

The same module as an ES module is the shape most new code takes; the checking is identical:

main.js TypeScript 5.9.3 TypeScript 5.9.3, run 21 October 2026: No errors. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. No errors. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. No errors. checked 21 October 2026
import { formatPrice, describeTag } from "./price.js";

/** @import { PriceTag } from "./price.js" */

/** @type {PriceTag} */
const lamp = { sku: "LAMP-01", cents: 3900 };

console.log(formatPrice(1999), describeTag(lamp));
The same module as an ES module, with named exports.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "types": ["node"]
  },
  "include": ["*.js"]
}

The same thing in TypeScript

The TypeScript equivalent tab of the first example is a .cts file. TypeScript writes CommonJS with export syntax and turns each export function into an exports.formatPrice = … assignment when it compiles; export = is the closer match to module.exports = { … }, but it cannot sit beside an exported type. JavaScript has no such restriction: the @typedef is exported alongside module.exports.

What goes wrong

Destructuring a name the module does not export is TS2305, and the required functions are checked like local ones, so a string where formatPrice wants a number is TS2345:

main.cjs Error confirmed 5.9.3 TypeScript 5.9.3, run 21 October 2026: main.cjs(2,9): error TS2305: Module '"./price.cjs"' has no exported member 'formatCost'. main.cjs(6,25): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. main.cjs(2,9): error TS2305: Module '"./price.cjs"' has no exported member 'formatCost'. main.cjs(6,25): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. main.cjs(2,9): error TS2305: Module '"./price.cjs"' has no exported member 'formatCost'. main.cjs(6,25): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'. checked 21 October 2026
const { formatCost } = require("./price.cjs");error TS2305: Module '"./price.cjs"' has no exported member 'formatCost'.
const { formatPrice } = require("./price.cjs");

console.log(formatPrice("19.99"), formatCost);error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
A name the module does not export, and a value passed where a PriceTag is expected.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "types": ["node"]
  },
  "include": ["*.cjs"]
}

Gotchas

  • require needs @types/node. Without it, require is an unknown name (TS2580, or TS2591 when the config has a types list). Install @types/node and list "node" in types if the config has a types list.
  • exports.name = … works too. Assigning properties one by one (exports.addTax = (cents) => …) declares the same named exports, typed from each value.
  • tsc does not catch mixed module syntax. TypeScript 5.9 accepts import statements in a .cjs file and module.exports in a .mjs file without an error, but Node.js refuses both when the file runs. Keep one syntax per file.

Questions

How does tsc know a file is CommonJS?

Under module: "nodenext" it follows Node.js: .cjs files are CommonJS, .mjs files are ES modules, and .js files follow the nearest package.json "type" field (CommonJS when it is missing or "commonjs").

Can an ES module import a CommonJS module?

Yes, as Node.js allows: import price from "./price.cjs" gives the module.exports object, and named imports work for the properties Node.js can detect. tsc checks both against the exported object's type.

Sources

Last reviewed 21 October 2026.