Node.js & TypeScript
Overview
Node.js paired with TypeScript is the dominant stack for full-stack JavaScript development. This reference covers project setup, TypeScript configuration, ESLint and Prettier workflows, and production patterns.
Project setup
Package manager choice
npm ships with Node and needs zero setup; pnpm is faster and disk-efficient because it shares a single content-addressable store across projects; yarn is the legacy alternative. Choose one and stick with it — mixing lockfiles in one repo causes conflicts.
# npm (default)
npm init -y
# pnpm (recommended — fast, disk-efficient)
npm install -g pnpm
pnpm init
# yarn
yarn init -y
TypeScript configuration
This tsconfig.json targets a modern Node runtime with NodeNext module resolution, strict type checking, and declarations emitted alongside the compiled JavaScript. rootDir and include scope compilation to src/ only, keeping dist/ clean and buildable.
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"sourceMap": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
package.json scripts
Scripts give every project command a standard, documented interface. tsx watch restarts instantly during development, tsc produces the production build, and the lint/format/typecheck checks keep the codebase consistent in CI.
{
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"test": "vitest",
"lint": "eslint src/",
"format": "prettier --write src/",
"format:check": "prettier --check src/",
"typecheck": "tsc --noEmit"
}
}
ESLint configuration
Flat config (modern)
ESLint 9's flat config replaces .eslintrc with a single exported array — no more cascading config files. typescript-eslint supplies the TS parser and recommended rulesets, and eslint-config-prettier must come last to disable any rules that would fight Prettier.
// eslint.config.js
import tseslint from "typescript-eslint";
import prettier from "eslint-config-prettier";
export default tseslint.config(
{ ignores: ["dist/", "node_modules/"] },
{ files: ["src/**/*.{ts,tsx}"], extends: [tseslint.configs.recommended] },
{
rules: {
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-explicit-any": "warn",
"no-console": "warn",
"eqeqeq": ["error", "always"],
},
},
prettier, // must be last to override formatting rules
);
Legacy .eslintrc format
Older projects and some scaffolding tools still use the JSON .eslintrc format. If you're maintaining a legacy codebase, this is the equivalent shape: @typescript-eslint/parser for TS syntax, the recommended rule set, and prettier listed last to avoid conflicts.
{
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"rules": {
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
"no-console": "warn",
"eqeqeq": "error"
}
}
Common ESLint commands
ESLint runs from the CLI or a lint script. --fix auto-corrects every safe issue, and --quiet suppresses warnings so CI fails only on real errors.
# Run linter
eslint src/
# Fix auto-fixable issues
eslint src/ --fix
# Lint specific file
eslint src/server.ts
# Quiet mode (errors only)
eslint src/ --quiet
Prettier configuration
Prettier formats code automatically, so formatting never becomes a code-review topic. The .prettierrc declares the conventions once (semicolons, quotes, width), and .prettierignore keeps generated files like build output and lockfiles untouched.
// .prettierrc
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "all",
"printWidth": 100,
"arrowParens": "always",
"endOfLine": "lf"
}
// .prettierignore
dist/
node_modules/
coverage/
pnpm-lock.yaml
package-lock.json
VS Code integration
These .vscode/settings.json entries make Prettier run on every save and ESLint auto-fix what it safely can — so formatting and linting happen in the editor exactly as they do in CI, without manual commands.
// .vscode/settings.json
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"eslint.validate": ["typescript", "typescriptreact"]
}
Common patterns
Express with TypeScript
Express is the classic Node web framework, and typing its request/response objects buys autocomplete and compile-time checks. Note the final error-handling middleware at the bottom: it catches anything thrown by the routes above it and returns a consistent JSON error.
// src/server.ts
import express, { Request, Response, NextFunction } from "express";
const app = express();
app.use(express.json());
interface User {
id: number;
name: string;
email: string;
}
const users: User[] = [];
app.get("/users", (_req: Request, res: Response) => {
res.json(users);
});
app.post("/users", (req: Request, res: Response) => {
const { name, email } = req.body;
const user: User = { id: users.length + 1, name, email };
users.push(user);
res.status(201).json(user);
});
// Centralized error handler
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
console.error(err);
res.status(500).json({ error: "Internal server error" });
});
app.listen(3000, () => console.log("Server on http://localhost:3000"));
Environment configuration
Validate configuration at startup, not mid-request. A zod schema parses process.env once, applies coercion and defaults, and fails fast with a descriptive error if a required variable is missing — so a misconfigured server never boots silently.
// src/config.ts
import { z } from "zod";
const envSchema = z.object({
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string(),
REDIS_URL: z.string().optional(),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
});
export const env = envSchema.parse(process.env);
Async error wrapper
Express 4 doesn't catch rejections from async handlers — an unhandled promise rejection can crash the process. asyncHandler wraps the promise and forwards any rejection to the error-handling middleware, so await failures return a 500 instead of killing the server.
// src/utils.ts
import { Request, Response, NextFunction } from "express";
type AsyncHandler = (req: Request, res: Response, next: NextFunction) => Promise<void>;
export const asyncHandler = (fn: AsyncHandler) => {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
// Usage
app.get(
"/users/:id",
asyncHandler(async (req, res) => {
const user = await findUserById(req.params.id);
if (!user) return res.status(404).json({ error: "Not found" });
res.json(user);
}),
);
API client with fetch
fetch is built into Node 18+, so no external client library is needed. A small typed wrapper centralizes headers and error handling — every call returns parsed data on success or throws a structured ApiError with the status code.
// src/client.ts
interface ApiError {
status: number;
message: string;
}
async function apiFetch<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(url, {
headers: { "Content-Type": "application/json", ...options?.headers },
...options,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const error: ApiError = { status: res.status, message: body.error || res.statusText };
throw error;
}
return res.json();
}
// Usage
const users = await apiFetch<User[]>("/users");
await apiFetch("/users", { method: "POST", body: JSON.stringify({ name: "John" }) });
See also
- Python Reference — FastAPI, uv, and scripting patterns
- Rust Reference — systems programming with cargo and axum