TypeScript on Node.js

Reviewed & published by Brayan K

Run TypeScript on the server with confidence. You'll set up a Node-friendly tsconfig.json, add @types/node, understand ESM vs CommonJS, use tsx/ts-node in development, compile with tsc for production, and work with typed process and fs.

Part of the free TypeScript course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn

💡 Real-World Analogy

TypeScript on Node is like writing in a language Node doesn't speak and using a translator. tsc is the careful translator that produces a polished JavaScript document ahead of time; tsx is the live interpreter that translates as you go. Either way, Node only ever hears JavaScript — the TypeScript is for you and the compiler.

1. A Node-Ready tsconfig.json

The config tells tsc what to emit and how to resolve imports. For modern Node, set "module" and "moduleResolution" to "NodeNext", choose an outDir, and turn on strict. Read the real file here:

// tsconfig.json — sensible settings for a modern Node project
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",          // emit modules the way Node loads them
    "moduleResolution": "NodeNext",// resolve imports like Node does
    "outDir": "dist",              // compiled .js goes here
    "rootDir": "src",              // .ts source lives here
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

// package.json (excerpt)
//   "type": "module"   -> treat .js as ES modules
//   Install Node's types:  npm install -D @types/node typescript

2. The Build-and-Run Cycle

Node runs JavaScript, so your TypeScript has to become JS first. Two modes cover everything: compile with tsc then run the output (production), or run the .ts directly with tsx/ts-node (development). The box below prints both flows.

// The core Node + TypeScript workflow has TWO modes.
// (We just PRINT the steps here so the box runs anywhere.)

// MODE A — build, then run (for production):
const buildSteps = [
  "1) Write code in   src/index.ts",
  "2) Compile:        npx tsc          (emits dist/index.js)",
  "3) Run the JS:     node dist/index.js",
];
console.log("Production build cycle:");
for (const step of buildSteps) console.log("   " + step);

// MODE B — run TypeScript directly (for fast development):
const devSteps = [
  "1) Write code in   src/index.ts",
  "2) Run it live:    npx tsx src/index.ts    (no separate build)",
];
console.log("\nDev cycle (tsx / ts-node):");
for (const step of devSteps) console.log("   " + step);

// Key point: Node itself runs JAVASCRIPT. Either tsc compiles to .js first,
// or a tool like tsx transpiles on the fly. The types are gone by run time.
console.log("\nNode always executes JavaScript — TypeScript is compiled away.");

3. The Typed process Global

Once @types/node is installed, Node's globals are fully typed. process.argv is a string[] of command-line arguments, and process.env holds environment variables (always string | undefined). The box simulates process so it runs here:

// Node exposes a global 'process'. With @types/node installed, every
// property is typed. We SIMULATE it here so the box runs in the browser.

const process = {
  argv: ["node", "script.js", "--port", "3000"],   // typed as string[]
  env: { NODE_ENV: "development", PORT: "3000" },   // typed as Record<string, string | undefined>
};

// process.argv: [execPath, scriptPath, ...your args]
const args = process.argv.slice(2);     // drop the first two entries
console.log("args:", args);             // [ '--port', '3000' ]

// A tiny flag reader:
function getFlag(name) {
  const i = args.indexOf("--" + name);
  return i >= 0 ? args[i + 1] : undefined;
}
console.log("port flag:", getFlag("port"));   // 3000

// process.env holds environment variables (always strings or undefined):
console.log("NODE_ENV:", process.env.NODE_ENV);   // development
const port = Number(process.env.PORT) || 8080;    // coerce, with a fallback
console.log("resolved port:", port);              // 3000

4. ESM vs CommonJS

CommonJS uses require() and module.exports; ES modules use import and export, enabled by "type": "module". A classic gotcha: __dirname exists in CommonJS but not in ESM. The read-only block above shows the real syntax; the box below models both systems:

// CommonJS (the classic Node style):
const fs = require("fs");
module.exports = { greet };

// ES Modules (modern, enabled by "type": "module"):
import fs from "node:fs";
export function greet(name: string) { return "Hi " + name; }

// __dirname exists in CommonJS but NOT in ESM. Rebuild it in ESM:
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

// process is a Node global, fully typed once @types/node is installed:
const args: string[] = process.argv.slice(2);
const home: string | undefined = process.env.HOME;
// ESM vs CommonJS — the two module systems, modelled as plain objects.
// In real Node you choose with package.json "type" (or .mjs / .cjs).

// --- CommonJS style: require() returns module.exports ---
function commonjsModule() {
  const moduleExports = {};
  moduleExports.add = (a, b) => a + b;        // module.exports.add = ...
  return moduleExports;                       // require("./math") gives this
}
const cjs = commonjsModule();
console.log("CJS require:", cjs.add(2, 3));    // 5

// --- ESM style: named exports, imported by name ---
const esmModule = { add: (a, b) => a + b, PI: 3.14159 };   // export { add, PI }
const { add, PI } = esmModule;                              // import { add, PI }
console.log("ESM import:", add(10, 20), PI);                // 30 3.14159

// Remember: __dirname EXISTS in CommonJS but NOT in ESM.
// In ESM you rebuild it from import.meta.url + fileURLToPath.
console.log("In ESM, derive __dirname from import.meta.url.");

Your turn. Fill in the two blanks marked ___ to read a --port argument, then run it.

// 🎯 YOUR TURN — replace each ___ then press "Try it Yourself".
// Read a "port" argument the way a Node CLI would.

const process = { argv: ["node", "app.js", "--port", "9000"] };

// 1) Skip the first TWO argv entries (node + script path)
const args = process.argv.slice(___);     // 👉 the number 2

// 2) Find the value that comes AFTER "--port"
const i = args.indexOf("--port");
const port = args[i + ___];               // 👉 the number 1 (the NEXT item)

console.log("args:", args);               // [ '--port', '9000' ]
console.log("port:", port);               // 9000

// ✅ Expected output:
//    args: [ '--port', '9000' ]
//    port: 9000

Common Errors (and the fix)

Pro Tips

Mini-Challenge: A Config Loader

No blanks this time — build loadConfig() yourself (env, coerced port, boolean debug), run it, and match your output to the example.

// 🎯 MINI-CHALLENGE: a tiny config loader
//
// Using the simulated process.env below, write loadConfig() that returns
// an object { env, port, debug }:
//   1. env   -> process.env.NODE_ENV, or "production" if missing
//   2. port  -> Number(process.env.PORT), or 8080 if missing/invalid
//   3. debug -> true only when process.env.DEBUG === "true"
//
// Test with the env provided, then log the result.
//
// ✅ Expected output:
//    { env: 'development', port: 3000, debug: true }

const process = { env: { NODE_ENV: "development", PORT: "3000", DEBUG: "true" } };

// your code here — then: console.log(loadConfig());

🎉 Lesson Complete

Practice quiz

Why install @types/node in a Node TypeScript project?

  • It provides type definitions for Node's built-in modules and globals
  • It compiles your code
  • It is the Node runtime
  • It speeds up Node

Answer: It provides type definitions for Node's built-in modules and globals. @types/node ships the type declarations for process, fs, path, Buffer, and other Node built-ins so the compiler knows them.

What does the tsc command do?

  • Installs Node types
  • Starts a dev server
  • Compiles TypeScript to JavaScript that Node can run
  • Runs the TypeScript file directly

Answer: Compiles TypeScript to JavaScript that Node can run. tsc (the TypeScript compiler) emits plain JavaScript; Node then runs that compiled .js output.

Which tool lets you run a .ts file directly during development without a separate build step?

  • npm
  • tsx (or ts-node)
  • eslint
  • prettier

Answer: tsx (or ts-node). tsx and ts-node transpile and execute TypeScript on the fly, which is convenient for development.

In tsconfig.json, what does the "module" option control?

  • The lint rules
  • The output folder
  • The Node version
  • The module system of the emitted JavaScript (e.g. CommonJS or ESM)

Answer: The module system of the emitted JavaScript (e.g. CommonJS or ESM). "module" sets the module format tsc emits, such as "CommonJS" or "NodeNext"/"ESNext" for ES modules.

How does Node decide a project uses ES modules rather than CommonJS?

  • "type": "module" in package.json (or a .mjs extension)
  • It always uses ESM
  • The presence of @types/node
  • The file is large

Answer: "type": "module" in package.json (or a .mjs extension). Setting "type": "module" in package.json (or using .mjs) tells Node to treat .js files as ES modules; otherwise they are CommonJS.

Which syntax loads a module in CommonJS?

  • include "x"
  • require("x")
  • import x from "x"
  • load("x")

Answer: require("x"). CommonJS uses require() and module.exports; ES modules use import/export.

What is a clean way to get the current directory in an ES module, where __dirname is not defined?

  • It is impossible in ESM
  • Just use __dirname, it always works
  • Use process.argv
  • Derive it from import.meta.url with fileURLToPath

Answer: Derive it from import.meta.url with fileURLToPath. In ESM, __dirname and __filename are not defined; you reconstruct them from import.meta.url using fileURLToPath and path.dirname.

For a typical Node project, what moduleResolution setting matches Node's behaviour with ESM?

  • "none"
  • "classic"
  • "NodeNext" (or "Node16")
  • "browser"

Answer: "NodeNext" (or "Node16"). "NodeNext"/"Node16" tells tsc to resolve modules the way modern Node does, including ESM rules and file extensions.

After running tsc, what do you actually execute with Node?

  • The node_modules folder
  • The compiled .js output
  • The tsconfig.json
  • The .ts source files

Answer: The compiled .js output. tsc emits JavaScript (often into a dist/ folder); you then run that compiled .js, for example node dist/index.js.

Where does Node read command-line arguments and environment variables from?

  • The process global (process.argv and process.env)
  • The window object
  • The fs module only
  • import.meta

Answer: The process global (process.argv and process.env). Node's process global exposes process.argv for arguments and process.env for environment variables; @types/node types them.

Continue this course

Frequently asked questions

Do I run TypeScript files directly with Node?

Node executes JavaScript, not TypeScript, so there are two paths. For production you compile with tsc, which emits .js (typically into a dist/ folder), then run that with `node dist/index.js`. For development you can skip the separate build using a tool like tsx or ts-node, which transpiles and runs the .ts file on the fly. Either way the types are erased before the code actually runs.

What does @types/node give me?

@types/node provides the TypeScript declarations for Node's built-in APIs and globals — process, fs, path, Buffer, the http module, and so on. Without it, the compiler doesn't know these exist and will flag them as errors. Install it as a dev dependency (npm install -D @types/node) alongside typescript itself.

How do I choose between CommonJS and ES modules?

CommonJS (require / module.exports) is the classic Node format; ES modules (import / export) are the modern standard. You opt into ESM by setting "type": "module" in package.json (or using the .mjs extension), and you set the matching tsconfig options "module": "NodeNext" and "moduleResolution": "NodeNext". New projects generally pick ESM, but plenty of existing code is CommonJS.

Why is __dirname undefined in my ES module?

__dirname and __filename are CommonJS-only conveniences; they don't exist in ES modules. In ESM you reconstruct them from import.meta.url: const __filename = fileURLToPath(import.meta.url) and const __dirname = dirname(__filename), importing fileURLToPath from node:url and dirname from node:path. This is one of the most common surprises when migrating to ESM.