TypeScript with Node.js — Full Setup

Sanjeev SharmaSanjeev Sharma
5 min read

Advertisement

Setting up TypeScript in Node.js requires more than just installing typescript. You need proper configuration, tooling, and build optimization. This guide walks you through a production-ready setup.

Project Initialization

# Create project directory
mkdir my-api
cd my-api

# Initialize Node.js project
npm init -y

# Install TypeScript and dependencies
npm install --save-dev typescript ts-node @types/node tsx

# Initialize TypeScript config
npx tsc --init

Configure tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "noImplicitThis": true,
    "alwaysStrict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "**/*.test.ts"]
}

Project Structure

my-api/
├── src/
│   ├── index.ts
│   ├── config/
│   │   └── database.ts
│   ├── models/
│   │   └── user.ts
│   ├── routes/
│   │   └── users.ts
│   ├── middleware/
│   │   └── errorHandler.ts
│   └── utils/
│       └── logger.ts
├── dist/
├── tests/
├── .env
├── package.json
├── tsconfig.json
└── README.md

Setup package.json Scripts

{
  "name": "my-api",
  "version": "1.0.0",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js",
    "lint": "eslint src --ext .ts",
    "type-check": "tsc --noEmit",
    "test": "vitest",
    "test:coverage": "vitest --coverage"
  },
  "devDependencies": {
    "@types/node": "^20.0.0",
    "typescript": "^5.0.0",
    "ts-node": "^10.0.0",
    "tsx": "^3.0.0",
    "@typescript-eslint/eslint-plugin": "^6.0.0",
    "@typescript-eslint/parser": "^6.0.0",
    "vitest": "^0.34.0"
  }
}

Basic Express Setup

// src/index.ts
import express, { Express, Request, Response } from "express";
import dotenv from "dotenv";

dotenv.config();

const app: Express = express();
const port = process.env.PORT || 3000;

// Middleware
app.use(express.json());

// Routes
app.get("/health", (req: Request, res: Response) => {
  res.json({ status: "OK" });
});

// Error handling
app.use((err: Error, req: Request, res: Response) => {
  console.error(err);
  res.status(500).json({ error: err.message });
});

app.listen(port, () => {
  console.log(`Server running on http://localhost:${port}`);
});

Type-Safe Configuration

// src/config/index.ts
interface Config {
  port: number;
  nodeEnv: "development" | "production" | "test";
  database: {
    url: string;
  };
  jwt: {
    secret: string;
    expiresIn: string;
  };
}

const getConfig = (): Config => {
  const nodeEnv = process.env.NODE_ENV as Config["nodeEnv"];

  if (!process.env.DATABASE_URL) {
    throw new Error("DATABASE_URL is required");
  }

  if (!process.env.JWT_SECRET) {
    throw new Error("JWT_SECRET is required");
  }

  return {
    port: parseInt(process.env.PORT || "3000", 10),
    nodeEnv: nodeEnv || "development",
    database: {
      url: process.env.DATABASE_URL,
    },
    jwt: {
      secret: process.env.JWT_SECRET,
      expiresIn: process.env.JWT_EXPIRES_IN || "24h",
    },
  };
};

export const config = getConfig();

Type-Safe Request Handlers

// src/routes/users.ts
import { Router, Request, Response, NextFunction } from "express";

interface User {
  id: number;
  name: string;
  email: string;
}

interface UserRequest extends Request {
  user?: User;
}

type Handler = (
  req: UserRequest,
  res: Response,
  next: NextFunction
) => Promise<void> | void;

const asyncHandler =
  (fn: Handler) => (req: UserRequest, res: Response, next: NextFunction) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };

const router = Router();

router.get(
  "/:id",
  asyncHandler(async (req: UserRequest, res: Response) => {
    const { id } = req.params;
    // Fetch user
    const user: User = { id: 1, name: "Alice", email: "alice@example.com" };
    res.json(user);
  })
);

export default router;

Environment Variables

# .env
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
JWT_SECRET=super_secret_key_change_in_production
// src/utils/env.ts
const getEnv = (key: string, defaultValue?: string): string => {
  const value = process.env[key];
  if (value === undefined) {
    if (defaultValue !== undefined) {
      return defaultValue;
    }
    throw new Error(`Environment variable ${key} is not defined`);
  }
  return value;
};

export const env = {
  port: parseInt(getEnv("PORT", "3000"), 10),
  nodeEnv: getEnv("NODE_ENV", "development"),
  databaseUrl: getEnv("DATABASE_URL"),
  jwtSecret: getEnv("JWT_SECRET"),
};

Development Workflow

# Watch mode with tsx
npm run dev

# Type checking
npm run type-check

# Build for production
npm run build

# Run production build
npm start

# Run tests
npm test

Debugging

{
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "Debug TypeScript",
      "program": "${workspaceFolder}/src/index.ts",
      "preLaunchTask": "tsc: build",
      "outFiles": ["${workspaceFolder}/dist/**/*.js"],
      "sourceMap": true,
      "cwd": "${workspaceFolder}"
    }
  ]
}

Production Build

# Clean and build
rm -rf dist && npm run build

# Package for deployment
npm prune --production
tar -czf app.tar.gz dist node_modules package.json package-lock.json

Docker Setup

FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --production

COPY dist ./dist

EXPOSE 3000

CMD ["node", "dist/index.js"]
# Build image
npm run build
docker build -t my-api .

# Run container
docker run -p 3000:3000 --env-file .env my-api

Common Issues and Solutions

Problem: Cannot find module errors

// Ensure export statements are present
export { userRouter };
export default app;

Problem: __dirname is not defined

import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

FAQ

Q: Should I use tsx or ts-node? A: tsx is faster and more reliable. Use tsx for development. ts-node is good for scripting but slower.

Q: How do I handle async/await without try-catch boilerplate? A: Use wrapper functions like asyncHandler above to automatically catch errors and pass to Express error handlers.

Q: What's the best way to organize a large TypeScript Node.js project? A: Follow feature-based structure: src/features/{feature}/routes.ts, services.ts, types.ts. This scales better than layer-based structure.


A proper TypeScript + Node.js setup takes 30 minutes but saves hours in debugging and maintenance. Start with this foundation and customize as your project grows.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro