Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Configuring a Strict TypeScript Node.js ESM Project

Hello. In the last lesson, you wrote generic functions that preserve precise types, including a lookup that returns a value or undefined. This lesson gives those functions a working home: a strict TypeScript project that compiles to JavaScript Node.js can run.

You’ll configure the compiler, make Node and TypeScript agree on ECMAScript modules, and use npm scripts to check, build, and run a small program. Allow about 40 minutes if you follow along at a terminal.


One project, two kinds of configuration

A TypeScript Node.js project has two related jobs. TypeScript checks your .ts files and emits .js files. Node.js runs those emitted files. The compiler can accept an import that Node cannot load unless their module settings agree.

Watch Beyond Fireship’s How to Setup Node.js with TypeScript in 2023 for the basic setup. We’ll add strict checking and a few safeguards that the short demonstration does not cover.

How to Setup Node.js with TypeScript in 2023

Beyond Fireship’s video establishes the project layout and shows TypeScript compiling code for Node.js. Watch for the distinction between configuring the compiler and configuring Node’s runtime.

Watch package setup for npm init, "type": "module", and the build script. Then watch compiler and imports for the NodeNext settings, emitted files, and the .js suffix required on local imports.

The "type" field deserves particular attention. It tells Node how to interpret .js files in a package; it does not turn TypeScript source files into executable JavaScript. Read the short explanation in the Node.js documentation before configuring your project.

Modules: Packages | Node.js v25.2.1 Documentation

The Node.js documentation explains why setting "type": "module" matters to the JavaScript your project will produce.

In “package.json and file extensions,” read the opening explanation. Start at the paragraph beginning “Within a package” and stop after the sentence explaining that "module" makes .js files use ES module syntax; you can skip the code example below it. Notice that the rule applies to files in the package, not just its entry point.

For this lesson, use a currently supported Node.js LTS release with npm. You can confirm both tools are available with node --version and npm --version.


Create a strict compiler configuration

In an empty directory, initialize npm and install TypeScript plus Node’s type declarations:

npm init -y
npm install --save-dev typescript @types/node
mkdir src

The typescript package provides the tsc compiler. @types/node supplies types for Node’s APIs; it does not change how Node runs your program. These are development dependencies because the emitted JavaScript does not need the TypeScript compiler to execute.

Create tsconfig.json at the project root:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "rootDir": "./src",
    "outDir": "./dist",
    "types": ["node"],
    "sourceMap": true,
    "noEmitOnError": true
  },
  "include": ["src/**/*.ts"]
}

Here, include selects the source files to compile. rootDir says where their source-tree layout begins, and outDir puts the resulting files under dist rather than beside the .ts files. With the two example files you’ll create shortly, the output paths will be dist/project.js and dist/index.js.

strict enables a family of stronger checks, including checks for implicit any and possibly absent values. The TSConfig reference clarifies what this flag does and why NodeNext resolution belongs with Node’s module behavior:

TSConfig Reference - Docs on every TSConfig option

The TypeScript documentation provides the precise meaning of two choices in this configuration: stronger type checking and Node-compatible import resolution.

Under “Strict - strict,” read the two paragraphs beginning with the explanation that the strict flag enables a range of checks, through the strict discussion. Then find “Module Resolution - moduleResolution.” In its first bullet, about node16 and nodenext, read from the sentence about Node supporting both import styles through the resolution explanation; stop before the node10 bullet. Focus on why resolution must reflect what Node will encounter in the emitted code.

The other settings serve distinct purposes. target selects the JavaScript language level emitted for a modern Node release. sourceMap creates mappings from JavaScript back to TypeScript for debugging. noEmitOnError prevents a build with type errors from emitting new JavaScript.

Notice that strict does not validate data arriving at runtime. It checks what can be established from your TypeScript code. Later in the module, you’ll handle untrusted runtime data separately.


Make source imports match emitted files

Set the package’s module type and add three scripts. From the project root, run:

npm pkg set type=module main=dist/index.js \
  'scripts.check=tsc --noEmit' \
  'scripts.build=tsc -p tsconfig.json' \
  'scripts.start=node dist/index.js'

If your shell does not support the line continuation shown above, enter it on one line. The relevant fields in package.json should now look like this; npm will retain the other fields and the development dependencies it added:

{
  "main": "dist/index.js",
  "type": "module",
  "scripts": {
    "check": "tsc --noEmit",
    "build": "tsc -p tsconfig.json",
    "start": "node dist/index.js"
  }
}

Now put the lookup from the previous lesson in src/project.ts:

export interface Project {
  id: string;
  title: string;
}

export function findById<Item extends { id: string }>(
  items: readonly Item[],
  id: string
): Item | undefined {
  return items.find((item) => item.id === id);
}

Create src/index.ts:

import { findById, type Project } from "./project.js";

const projects: Project[] = [
  { id: "p1", title: "Portfolio" }
];

const project = findById(projects, "p1");
console.log(project?.title ?? "Project not found");

The import says "./project.js" even though the source file is project.ts. This is intentional. TypeScript’s NodeNext resolution can find and check the .ts source corresponding to that import. It leaves a JavaScript import that points to dist/project.js, which Node can load after compilation. Node’s native ESM resolution needs that explicit extension for this relative file import. Project is imported as a type; it is used during checking and does not become a runtime import.

Run the scripts in order:

npm run check
npm run build
npm start

check reports type errors without creating output. build compiles the files into dist. start runs the compiled entry point and should print Portfolio. An npm script can invoke tsc without a global TypeScript installation because npm makes locally installed command-line tools available to scripts.

There are two useful failure modes to recognize. If you change the import to "./project" and run npm run check, NodeNext should flag the missing extension. If you change the final line to console.log(project.title), strict checking should object because findById can return undefined. Restore the .js import and the handled lookup before building. These errors come from different boundaries: one concerns whether Node can resolve the emitted import; the other concerns whether the value is safe to use.


Key takeaways

A runnable TypeScript project needs both compiler settings and a compatible Node package setting. strict strengthens checks on source code; paired NodeNext settings make TypeScript account for Node’s module resolution; and "type": "module" tells Node to run the emitted .js files as ES modules. Keep .js suffixes on relative source imports when those are the paths the emitted JavaScript must use. Finally, separate check, build, and start scripts make each stage easy to run and diagnose.

Next, you’ll use this Node.js foundation to distinguish expected operational failures from programming errors in asynchronous code.

Can't find a good explanation? Sign up and we'll make it for you

Sign up