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 TypeScript Node.js Development, Build, and Production Workflows

Hello! This module begins with the runtime foundation for a backend service: how your TypeScript source becomes code that Node.js runs. Later lessons will add startup configuration, request handling, and reliability concerns. For now, you’ll set up one small HTTP application with three deliberately different workflows: develop from source, build JavaScript, and run the built JavaScript.

By the end, you should be able to explain what each command runs, identify the file that would start in production, and catch type errors without relying on the development runner to do it for you. Plan on about 40 minutes, including the setup and verification.


What each workflow is for

Node.js runs the application’s JavaScript output. TypeScript’s compiler, tsc, checks your source and emits that output according to tsconfig.json. During development, a tool can run .ts files conveniently and restart when you edit them; that convenience does not replace a checked production build.

Start by creating an npm project. Robin Wieruch’s TypeScript in a Node.js Project gives the short explanation of why package.json comes first.

TypeScript in a Node.js Project - Robin Wieruch

Read Robin Wieruch’s project-initialization section to see what npm creates and what role package.json plays.

In “Setup Node.js Project,” read the setup steps. Begin with the paragraph about creating a dedicated folder and stop before the “TypeScript in Node.js” heading. Focus on npm init -y and the resulting package.json.

In a terminal, create a directory of your choice and run:

npm init -y
npm install -D typescript @types/node tsx

Here, typescript supplies tsc; @types/node supplies types for Node APIs; and tsx is a development runner. They are development dependencies because the production command will run emitted JavaScript, not these tools. Keep the generated package-lock.json: it records the dependency versions for reproducible installs.


Make the compiler match the Node.js runtime

Module settings are not merely a stylistic choice. They determine how TypeScript interprets imports and what imports remain in the emitted JavaScript. This project will use ES modules: "type": "module" in package.json and Node-aware module settings in tsconfig.json.

Read the Node.js-specific recommendation in the TypeScript documentation rather than copying settings intended for a browser bundler.

Documentation - Modules - Choosing Compiler Options

The TypeScript documentation distinguishes code compiled for Node.js from code compiled for a bundler. Use its Node.js recommendation to understand the module choices below.

Find “I’m compiling and running the outputs in Node.js.” Read the ESM guidance together with the JSON compiler-options example immediately below it. Stop at the next heading, “I’m using ts-node.” Focus on nodenext and the relationship between the emitted modules and "type": "module" in package.json; the example explicitly leaves other settings for you to choose.

Add "type": "module" to the top level of your existing package.json. Create tsconfig.json at the project root:

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

This configuration assumes a modern Node.js runtime (Node 20 or newer). rootDir and outDir place authored code under src/ and emitted code under dist/. strict enables stronger type checking; noEmitOnError prevents emission when the compiler finds errors. NodeNext makes TypeScript check imports against Node’s module rules rather than assuming a bundler will repair them.

One rule may initially look odd: in a TypeScript file that imports another local file, write the output file’s .js extension, even though the source file ends in .ts. TypeScript finds the .ts source for checking and preserves the .js specifier for Node to use after compilation.

Create these two files:

// src/greeting.ts
export function greeting(): string {
  return "Hello from the backend";
}
// src/index.ts
import { createServer } from "node:http";
import { greeting } from "./greeting.js";

const server = createServer((request, response) => {
  if (request.url !== "/") {
    response.writeHead(404);
    response.end("Not found");
    return;
  }

  response.writeHead(200, {
    "content-type": "text/plain; charset=utf-8"
  });
  response.end(greeting());
});

server.listen(3000, () => {
  console.log("Listening on http://localhost:3000");
});

The server is intentionally small. Its purpose is to give the workflows a real entry point and a local import whose behavior you can inspect in both source and output.


Give development, checking, building, and starting separate commands

Wieruch’s guide uses tsx for local execution, tsc for the build, and node for the compiled result. Read that distinction before adding the scripts.

TypeScript in a Node.js Project - Robin Wieruch

Return to Robin Wieruch’s guide for the three script roles and the optional file-watching development mode.

In “Run & Compile TypeScript Files in Node.js,” start with the runner and scripts. Continue through the explanation of --watch, then stop before “Environment Variables in Node.js.” Focus on which file each command runs. The guide’s “recompile and reload” wording describes the development runner’s behavior, not a production build being written to dist/.

There is one useful addition to those three scripts: an explicit check command. tsx makes development quick, but running successfully under tsx does not establish that the TypeScript project passes tsc. A build should be checked separately.

For clean builds, create scripts/clean.mjs:

import { rm } from "node:fs/promises";

await rm(new URL("../dist", import.meta.url), {
  recursive: true,
  force: true
});

Removing the old output first avoids leaving a compiled file behind after its source is deleted. Merge this scripts object into your existing package.json:

{
  "scripts": {
    "dev": "tsx --watch src/index.ts",
    "check": "tsc --noEmit",
    "build": "node scripts/clean.mjs && tsc",
    "start": "node dist/index.js"
  }
}

The resulting responsibilities are distinct:

Command What it uses Purpose
npm run dev src/index.ts via tsx Run locally and restart on edits
npm run check Source via tsc Report type errors without emitting files
npm run build Source via tsc Replace dist/ with checked JavaScript output
npm start dist/index.js via Node Run an already built application

In particular, start does not quietly build the project. That separation makes a missing build visible and lets production run a known artifact rather than recompiling source at startup.


Trace an edit through all three workflows

First run npm run dev. Open http://localhost:3000/ in a browser, or request it with a command-line HTTP client. You should receive Hello from the backend. Change the returned string in src/greeting.ts and request the page again; the watch runner should restart the server. Stop it before moving on.

Next run these commands in order:

npm run check
npm run build
npm start

Request the same URL again. This time Node is running dist/index.js. Inspect dist/index.js and dist/greeting.js: they are JavaScript files, and the local import still names ./greeting.js. If you edit src/greeting.ts now, the running production-style process will not acquire that edit. It takes another build and restart to run a new artifact.

If check or build reports an error, fix the source rather than starting an older copy of dist/. In a deployment workflow, the build happens before the artifact is released; the production environment receives the built files and any runtime dependencies, then starts the application with Node. Development-only packages such as tsx need not be present to execute dist/index.js.

A useful final inspection is your project tree: source belongs in src/, generated output in dist/, and the compiler configuration and npm scripts at the project root. Do not commit node_modules/ or dist/ as source; add both to .gitignore, while retaining package-lock.json.


Takeaway

You now have a TypeScript Node.js project in which local convenience and production behavior are intentionally different. tsx watches source for development, tsc checks and emits JavaScript, and Node runs the emitted entry point. The ESM settings and .js import specifiers ensure that an import which passes TypeScript’s checks also makes sense to Node after the build.

In the next lesson, the application will gain startup configuration: validating environment variables once and exposing them through a typed object rather than reading unverified values throughout the code.

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

Sign up