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
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:
{
"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"]
}module: "NodeNext" and moduleResolution: "NodeNext" for modern ESM projects. Use CommonJS for classic require-based projects.Scripts in package.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):
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:
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:
// 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); // stringundefined.Reading and Writing Files
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
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
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
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
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
npm install --save-dev tsconfig-paths
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@config/*": ["src/config/*"],
"@utils/*": ["src/utils/*"],
"@models/*": ["src/models/*"]
}
}
}// 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
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.jsonCommon @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
Install typescript, ts-node, and @types/node as dev dependencies
Set target to ES2022 and module to CommonJS (or NodeNext for ESM)
Validate process.env at startup — centralise config in a single typed module
Use fs/promises for async file operations — all return typed Promises
Use promisify() to convert callback-style Node APIs to Promises
Typed EventEmitter pattern provides compile-time checking for event names and payloads
Path aliases with tsconfig-paths eliminate deep relative imports
Always compile with tsc for production — ts-node is for development only