TypeScriptTypeScript with Node.js

TypeScript with Node.js

Node.js and TypeScript are a natural pairing. TypeScript catches type errors in server-side code at compile time, provides first-class autocompletion for Node.js APIs, and scales gracefully from small scripts to large microservices.

This page covers everything you need to set up and work effectively with TypeScript in a Node.js project.

Initial Setup

Bash
mkdir my-node-app && cd my-node-app
npm init -y
npm install --save-dev typescript ts-node @types/node
npx tsc --init

The key tsconfig.json settings for Node.js:

JSON
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "CommonJS",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "declaration": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
Note
Use module: "NodeNext" and moduleResolution: "NodeNext" for modern ESM projects. Use CommonJS for classic require-based projects.
Scripts in package.json

JSON
{
  "scripts": {
    "build":  "tsc",
    "start":  "node dist/index.js",
    "dev":    "ts-node src/index.ts",
    "dev:watch": "ts-node-dev --respawn --transpile-only src/index.ts"
  }
}

For production, compile with tsc first. For development, use ts-node or ts-node-dev (which restarts on file changes):

Bash
npm install --save-dev ts-node-dev
Working with Node.js Core APIs

The @types/node package provides types for all built-in Node modules. Import them as ES modules or with require:

TS
import * as fs   from 'fs';
import * as path from 'path';
import * as os   from 'os';

// fs.readFile callback — error is NodeJS.ErrnoException | null
fs.readFile('/etc/hosts', 'utf8', (err, data) => {
  if (err) {
    console.error(err.code);   // string — e.g. 'ENOENT'
    console.error(err.path);   // string | undefined
    return;
  }
  console.log(data.toUpperCase());
});

// Async versions return typed Promises
import { readFile, writeFile, mkdir } from 'fs/promises';

async function readConfig(configPath: string): Promise<string> {
  const abs = path.resolve(configPath);
  return readFile(abs, 'utf8');
}

// process.env is Record<string, string | undefined>
const port = process.env['PORT'];   // string | undefined
const portNum = port ? parseInt(port, 10) : 3000;
Environment Variables — Typed Config

process.env values are all string | undefined. Centralise and validate them at startup:

TS
// src/config.ts
function requireEnv(key: string): string {
  const value = process.env[key];
  if (!value) throw new Error(`Missing required environment variable: ${key}`);
  return value;
}

function optionalEnv(key: string, fallback: string): string {
  return process.env[key] ?? fallback;
}

export const config = {
  port:        parseInt(optionalEnv('PORT', '3000'), 10),
  databaseUrl: requireEnv('DATABASE_URL'),
  jwtSecret:   requireEnv('JWT_SECRET'),
  nodeEnv:     optionalEnv('NODE_ENV', 'development') as 'development' | 'production' | 'test',
} as const;

// Usage — everything is typed with no undefined
import { config } from './config';
console.log(config.port);        // number
console.log(config.databaseUrl); // string
Tip
Validate all required environment variables at startup so the process fails fast with a clear error rather than crashing later with a cryptic undefined.
Reading and Writing Files

TS
import { readFile, writeFile, access, constants } from 'fs/promises';
import * as path from 'path';

interface AppData {
  users:    string[];
  lastSync: string;
}

async function loadData(filePath: string): Promise<AppData> {
  try {
    await access(filePath, constants.R_OK);
    const raw = await readFile(filePath, 'utf8');
    return JSON.parse(raw) as AppData;
  } catch {
    // File does not exist — return default
    return { users: [], lastSync: new Date().toISOString() };
  }
}

async function saveData(filePath: string, data: AppData): Promise<void> {
  const dir = path.dirname(filePath);
  await import('fs/promises').then(fs => fs.mkdir(dir, { recursive: true }));
  await writeFile(filePath, JSON.stringify(data, null, 2), 'utf8');
}

// Usage
const data = await loadData('./data/app.json');
data.users.push('alice');
await saveData('./data/app.json', data);
HTTP Server with the http Module

TS
import * as http from 'http';

interface ApiResponse<T> {
  data:    T | null;
  error:   string | null;
  status:  number;
}

function sendJSON<T>(res: http.ServerResponse, status: number, payload: ApiResponse<T>): void {
  res.writeHead(status, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify(payload));
}

const server = http.createServer((req: http.IncomingMessage, res: http.ServerResponse) => {
  const url    = req.url ?? '/';
  const method = req.method ?? 'GET';

  if (url === '/health' && method === 'GET') {
    sendJSON(res, 200, { data: { ok: true }, error: null, status: 200 });
    return;
  }

  sendJSON(res, 404, { data: null, error: 'Not found', status: 404 });
});

server.listen(3000, () => {
  console.log('Server running on http://localhost:3000');
});
Streams

TS
import { createReadStream, createWriteStream } from 'fs';
import { Transform, TransformCallback } from 'stream';
import { pipeline } from 'stream/promises';

class UpperCaseTransform extends Transform {
  _transform(chunk: Buffer, _encoding: BufferEncoding, callback: TransformCallback): void {
    this.push(chunk.toString().toUpperCase());
    callback();
  }
}

async function processFile(input: string, output: string): Promise<void> {
  await pipeline(
    createReadStream(input),
    new UpperCaseTransform(),
    createWriteStream(output)
  );
  console.log('Done');
}

processFile('./input.txt', './output.txt').catch(console.error);
Working with child_process

TS
import { exec, spawn } from 'child_process';
import { promisify } from 'util';

const execAsync = promisify(exec);

interface ExecResult {
  stdout: string;
  stderr: string;
}

async function runCommand(cmd: string): Promise<ExecResult> {
  try {
    return await execAsync(cmd);
  } catch (err) {
    // err is typed as ExecException in @types/node
    const execErr = err as NodeJS.ErrnoException;
    throw new Error(`Command failed: ${execErr.message}`);
  }
}

const { stdout } = await runCommand('git log --oneline -5');
console.log(stdout);

// spawn for long-running processes — stdout/stderr are streams
const child = spawn('node', ['worker.js'], { stdio: 'pipe' });

child.stdout?.on('data', (data: Buffer) => {
  process.stdout.write(data);
});

child.on('close', (code: number | null) => {
  console.log(`Child exited with code ${code ?? 'null'}`);
});
Event Emitter Pattern

TS
import { EventEmitter } from 'events';

// Typed event emitter using a map interface
interface WorkerEvents {
  'start':    [jobId: string];
  'progress': [jobId: string, percent: number];
  'done':     [jobId: string, result: unknown];
  'error':    [jobId: string, err: Error];
}

class TypedEmitter<Events extends Record<string, unknown[]>> extends EventEmitter {
  emit<K extends string & keyof Events>(event: K, ...args: Events[K]): boolean {
    return super.emit(event, ...args);
  }

  on<K extends string & keyof Events>(event: K, listener: (...args: Events[K]) => void): this {
    return super.on(event, listener as (...args: unknown[]) => void);
  }
}

class JobWorker extends TypedEmitter<WorkerEvents> {
  async run(jobId: string) {
    this.emit('start', jobId);
    // simulate work
    for (let i = 0; i <= 100; i += 10) {
      await new Promise(r => setTimeout(r, 50));
      this.emit('progress', jobId, i);
    }
    this.emit('done', jobId, { result: 'completed' });
  }
}

const worker = new JobWorker();
worker.on('progress', (id, pct) => console.log(`${id}: ${pct}%`));
worker.on('done',     (id, result) => console.log(`${id} finished`, result));
worker.run('job-42');
Path Aliases with tsconfig-paths

Bash
npm install --save-dev tsconfig-paths

JSON
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@config/*": ["src/config/*"],
      "@utils/*":  ["src/utils/*"],
      "@models/*": ["src/models/*"]
    }
  }
}

TS
// tsconfig-paths/register makes the aliases work at runtime
// Add to your dev script:
// ts-node -r tsconfig-paths/register src/index.ts

import { config }      from '@config/app';
import { parseDate }   from '@utils/date';
import type { User }   from '@models/user';
TypeScript Node.js Project Structure

Bash
my-node-app/
  src/
    index.ts          # Entry point
    config.ts         # Environment config
    routes/           # Route handlers
    services/         # Business logic
    models/           # Data models / interfaces
    utils/            # Shared utilities
    middleware/       # Express middleware (if using Express)
  dist/               # Compiled output (git-ignored)
  .env                # Environment variables (git-ignored)
  tsconfig.json
  package.json
Common @types Packages

Package

@types package

Purpose

Node.js built-ins

@types/node

fs, path, http, stream, child_process, etc.

Express

@types/express

Request, Response, NextFunction, Router

Jest

@types/jest

describe, it, expect, beforeEach, etc.

lodash

@types/lodash

All lodash utility functions

multer

@types/multer

File upload middleware types

jsonwebtoken

@types/jsonwebtoken

JWT sign/verify types

Key Takeaways
  1. Install typescript, ts-node, and @types/node as dev dependencies

  2. Set target to ES2022 and module to CommonJS (or NodeNext for ESM)

  3. Validate process.env at startup — centralise config in a single typed module

  4. Use fs/promises for async file operations — all return typed Promises

  5. Use promisify() to convert callback-style Node APIs to Promises

  6. Typed EventEmitter pattern provides compile-time checking for event names and payloads

  7. Path aliases with tsconfig-paths eliminate deep relative imports

  8. Always compile with tsc for production — ts-node is for development only