TypeScript Node.js Project Setup — Production-Ready 2024

Sanjeev SharmaSanjeev Sharma
5 min read

Advertisement

Introduction

Why This Matters

A TypeScript Node.js project done right gives you fast iteration in development and reliable, optimized builds in production. Done wrong, it creates confusion between the source and compiled files, slow hot-reload loops, mysterious __dirname errors with ESM, and uncaught type errors in CI.

The ecosystem has converged on clear best practices in 2024: tsx for development hot-reload, tsc for production compilation, strict tsconfig flags, and environment variable validation at startup. These choices reduce bugs and developer frustration.

Getting the setup correct from the start also makes the project easier to containerize, test, and onboard new developers to. A well-structured TypeScript project is self-documenting — the directory layout, config files, and type annotations tell the full story.

Project Initialization

mkdir my-api && cd my-api
npm init -y
 
# TypeScript and Node types
npm install --save-dev typescript @types/node
 
# Fast development runner (no compile step)
npm install --save-dev tsx
 
# Production dependencies
npm install express dotenv
npm install --save-dev @types/express
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "noImplicitReturns": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "moduleResolution": "node"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}

Project Structure

my-api/
├── src/
│   ├── index.ts              # Entry point
│   ├── app.ts                # Express app setup (testable)
│   ├── config/
│   │   └── index.ts          # Validated environment config
│   ├── features/
│   │   └── users/
│   │       ├── user.types.ts
│   │       ├── user.service.ts
│   │       ├── user.router.ts
│   │       └── user.service.test.ts
│   ├── middleware/
│   │   ├── errorHandler.ts
│   │   └── requestLogger.ts
│   └── types/
│       └── index.ts          # Shared domain types
├── dist/                     # Compiled output (gitignored)
├── .env
├── .env.example
├── package.json
├── tsconfig.json
└── Dockerfile

package.json Scripts

{
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc --project tsconfig.json",
    "start": "node dist/index.js",
    "type-check": "tsc --noEmit",
    "lint": "eslint src --ext .ts --max-warnings 0",
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage",
    "clean": "rm -rf dist"
  }
}

Environment Variable Validation

Validate environment variables at startup — fail fast rather than crash mid-request.

// src/config/index.ts
interface AppConfig {
  port: number;
  nodeEnv: 'development' | 'staging' | 'production' | 'test';
  databaseUrl: string;
  jwtSecret: string;
  jwtExpiresIn: string;
}
 
function require_env(key: string): string {
  const value = process.env[key];
  if (!value) throw new Error(`Missing required environment variable: ${key}`);
  return value;
}
 
function optional_env(key: string, fallback: string): string {
  return process.env[key] ?? fallback;
}
 
export const config: AppConfig = {
  port: parseInt(optional_env('PORT', '3000'), 10),
  nodeEnv: (optional_env('NODE_ENV', 'development')) as AppConfig['nodeEnv'],
  databaseUrl: require_env('DATABASE_URL'),
  jwtSecret: require_env('JWT_SECRET'),
  jwtExpiresIn: optional_env('JWT_EXPIRES_IN', '24h'),
};

Application Entry Point

Keep index.ts thin — separate app setup from server startup for testability.

// src/app.ts
import express, { Express } from 'express';
import { errorHandler } from './middleware/errorHandler';
import { requestLogger } from './middleware/requestLogger';
import { userRouter } from './features/users/user.router';
 
export function createApp(): Express {
  const app = express();
 
  app.use(express.json());
  app.use(express.urlencoded({ extended: true }));
  app.use(requestLogger);
 
  app.get('/health', (_req, res) => {
    res.json({ status: 'ok', timestamp: new Date().toISOString() });
  });
 
  app.use('/api/users', userRouter);
  app.use(errorHandler);
 
  return app;
}
// src/index.ts
import 'dotenv/config';
import { createApp } from './app';
import { config } from './config';
 
const app = createApp();
 
const server = app.listen(config.port, () => {
  console.log(`Server running on http://localhost:${config.port} [${config.nodeEnv}]`);
});
 
process.on('SIGTERM', () => {
  server.close(() => {
    console.log('Server closed gracefully');
    process.exit(0);
  });
});

Type-Safe Middleware

// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
 
interface AppError extends Error {
  status?: number;
  code?: string;
}
 
export function errorHandler(
  err: AppError,
  _req: Request,
  res: Response,
  _next: NextFunction
): void {
  const status = err.status ?? 500;
  const message = status === 500 ? 'Internal server error' : err.message;
 
  if (status === 500) {
    console.error('[Error]', err);
  }
 
  res.status(status).json({
    status: 'error',
    message,
    code: err.code ?? 'INTERNAL_ERROR',
  });
}

Docker for Production

# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
 
# Production stage
FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3000
USER node
CMD ["node", "dist/index.js"]

Common Mistakes

  • Not separating app.ts from index.ts — makes integration testing impossible
  • Using ts-node in production — it compiles on startup and adds latency
  • Skipping environment validation — null errors crash at the worst possible time
  • Committing the dist/ directory — add it to .gitignore
  • Using noUnusedLocals: false — unused variables are technical debt in disguise

Best Practices

  • Run npm run type-check in CI before building — catch type errors without emitting files
  • Use tsx watch for development — it has no compilation step and restarts on file changes
  • Pin @types/node to the major version matching your Node.js runtime
  • Add .env.example with all required variables documented but no real values
  • Enable ESLint with @typescript-eslint rules alongside the TypeScript compiler for style consistency

Key Takeaways

  • tsx is the fastest development runner — it transpiles TypeScript natively without a compile step
  • tsc produces the production build; node dist/index.js runs it with zero TypeScript overhead
  • strict: true in tsconfig is mandatory for production-grade type safety
  • Separate createApp() from the server listen call to make integration tests clean and fast
  • Validate all environment variables at startup with explicit errors — never let missing config cause silent failures
  • Multi-stage Docker builds keep production images small by leaving dev dependencies in the build stage
  • Feature-based directory structure (features/users/) scales better than layer-based (routes/, services/, models/)
  • sourceMap: true enables accurate stack traces from compiled JavaScript back to TypeScript source lines

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading