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:
/**
* @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)}`;
}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:
const { formatPrice, describeTag } = require("./price.cjs");
/** @type {import("./price.cjs").PriceTag} */
const lamp = { sku: "LAMP-01", cents: 3900 };
console.log(formatPrice(1999), describeTag(lamp));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:
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));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:
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'.jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": ["node"]
},
"include": ["*.cjs"]
} Gotchas
requireneeds@types/node. Without it,requireis an unknown name (TS2580, or TS2591 when the config has atypeslist). Install@types/nodeand list"node"intypesif the config has atypeslist.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
importstatements in a.cjsfile andmodule.exportsin a.mjsfile 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
- Modules (CommonJS modules) · Node.js documentation
- Modules Reference (module and moduleResolution) · TypeScript handbook
- Type Checking JavaScript Files (CommonJS modules) · TypeScript handbook
Last reviewed 21 October 2026.