Quick start · project shape

Type-checked JavaScript for Node.js with ES modules

This is the setup for a Node.js app, script or server written as ES modules: "type": "module" in package.json, import and export in your .js files. TypeScript checks it with module and moduleResolution set to nodenext, which follow the rules Node.js itself uses to load files.

  • Node.js (ES modules)
  • 2 dev dependencies
  • npm run lint

The jsconfig.json, the package.json scripts and the two files at the end of this page are one real project, checked with TypeScript 5.9 every time this site is built.

Install

TypeScript is a development dependency: it checks your code and never runs in production. @types/node gives the checker the types of Node's built-in modules (node:fs, node:http, process and the rest). Pick the @types/node major version that matches the Node.js you run, for example @types/node@24 for Node.js 24.

Terminal
npm install --save-dev typescript @types/node

jsconfig.json

nodenext makes TypeScript resolve imports the way Node.js does: a relative import needs its file extension (./config.js, not ./config), and a package's exports field decides what you can import from it. target: "es2024" matches the JavaScript that current Node.js releases run.

types: ["node"] loads only Node's types as globals, so an unrelated @types package somewhere in node_modules cannot add globals you never use. skipLibCheck skips checking the .d.ts files of your dependencies, which saves time and avoids errors you cannot fix; your own files are still checked in full.

jsconfig.json
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "es2024",
    "types": ["node"],
    "skipLibCheck": true
  },
  "include": ["src/**/*.js"]
}

npm scripts

lint checks the whole project once, which is what CI should run. lint:watch keeps tsc running and checks again on every save; --preserveWatchOutput keeps the earlier output on screen instead of clearing it.

package.json
{
  "type": "module",
  "scripts": {
    "lint": "tsc -p jsconfig.json",
    "lint:watch": "tsc -p jsconfig.json --watch --preserveWatchOutput"
  }
}

What nodenext checks in your imports

With this config, the checker holds your imports to Node's rules:

  • relative imports carry their extension: import { loadConfig } from "./config.js"; leaving it off is TS2835, shown below
  • built-in modules work with or without the node: prefix; the prefix makes it obvious which imports are built in
  • top-level await is allowed, because ES modules allow it (the example's main.js uses it)
main.js Error confirmed 5.9.3 TypeScript 5.9.3, run 21 October 2026: main.js(2,29): error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './format.js'? 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. main.js(2,29): error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './format.js'? 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. main.js(2,29): error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './format.js'? checked 21 October 2026
import { formatPrice } from "./format";error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './format.js'?

console.log(formatPrice(1999));
A relative import without its extension.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "es2024",
    "types": ["node"],
    "skipLibCheck": true
  },
  "include": ["*.js"]
}

The example project

A two-file server: src/config.js reads and types a JSON config file, and src/main.js imports it and starts an HTTP server. Both files are checked with the jsconfig.json above.

src/config.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 { readFile } from "node:fs/promises";

/**
 * @typedef {object} Config
 * @property {number} port
 * @property {string} [host]
 */

/**
 * @param {URL} file
 * @returns {Promise<Config>}
 */
export async function loadConfig(file) {
  const text = await readFile(file, "utf8");
  /** @type {Config} */
  const config = JSON.parse(text);
  return { host: "localhost", ...config };
}
The config module: reads config.json and returns a typed Config.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "es2024",
    "types": ["node"],
    "skipLibCheck": true
  },
  "include": ["src/**/*.js"]
}
src/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 { createServer } from "node:http";
import { loadConfig } from "./config.js";

const config = await loadConfig(new URL("../config.json", import.meta.url));

createServer((req, res) => {
  res.end(`You asked for ${req.url}`);
}).listen(config.port, config.host);
The server, which imports loadConfig and uses what it returns.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "target": "es2024",
    "types": ["node"],
    "skipLibCheck": true
  },
  "include": ["src/**/*.js"]
}

In your editor

VS Code reads this jsconfig.json with no further setup. To make it use the same TypeScript as npm run lint, see VS Code.

Sources

Last reviewed 20 October 2026.