TypeScript Node.js Project Setup — Production-Ready 2024
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/expressRecommended tsconfig.json
{
"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
└── Dockerfilepackage.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.tsfromindex.ts— makes integration testing impossible - Using
ts-nodein 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-checkin CI before building — catch type errors without emitting files - Use
tsx watchfor development — it has no compilation step and restarts on file changes - Pin
@types/nodeto the major version matching your Node.js runtime - Add
.env.examplewith all required variables documented but no real values - Enable ESLint with
@typescript-eslintrules alongside the TypeScript compiler for style consistency
Key Takeaways
tsxis the fastest development runner — it transpiles TypeScript natively without a compile steptscproduces the production build;node dist/index.jsruns it with zero TypeScript overheadstrict: truein 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: trueenables accurate stack traces from compiled JavaScript back to TypeScript source lines
Advertisement