TypeScript in Node.js is one of those things that feels like overhead until you’ve shipped something without it and spent an afternoon debugging a cannot read property of undefined that a compiler would have caught at zero cost.
This guide sets up TypeScript in an Express project from scratch — not just the boilerplate, but the reasoning behind each decision.
Initialize the project
mkdir ts-node-app
cd ts-node-app
npm init -y
Install dependencies
TypeScript and its type definitions are development dependencies. The compiled output is plain JavaScript, so none of the TypeScript tooling ships to production.
# Runtime
npm install express
# Development only
npm install -D typescript ts-node @types/node @types/express
typescript— the compilerts-node— runs.tsfiles directly without a build step, useful for development@types/nodeand@types/express— type definitions for Node’s built-ins and Express’s API
Configure TypeScript
Generate a tsconfig.json:
npx tsc --init
Then configure it for a Node.js target:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "build"]
}
Key options:
strict: true— enables all strict checks. This is the point of TypeScript; don’t disable it.outDir: "./build"— compiled JS goes here, never commit itesModuleInterop: true— allowsimport express from 'express'instead ofimport * as express from 'express'
Project structure
ts-node-app/
├── src/
│ ├── app.ts ← Express app setup
│ └── server.ts ← entry point, starts the server
├── build/ ← compiled output (gitignored)
├── tsconfig.json
└── package.json
Write the Express app
// src/app.ts
import express, { Application, Request, Response, NextFunction } from 'express';
const app: Application = express();
app.use(express.json());
app.get('/', (req: Request, res: Response) => {
res.json({ status: 'ok', message: 'Hello, TypeScript.' });
});
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error(err.stack);
res.status(500).json({ error: err.message });
});
export default app;
// src/server.ts
import app from './app';
const PORT = process.env.PORT ?? 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Separating app from server makes the app importable in tests without binding to a port.
Add build scripts
{
"scripts": {
"dev": "ts-node src/server.ts",
"build": "tsc --project ./",
"start": "node ./build/server.js"
}
}
npm run dev— development, no compilation step, instant feedbacknpm run build— compiles TypeScript to./buildnpm start— runs the compiled output in production
Type-safe route handlers
The real value shows up when you start defining typed request/response shapes:
// src/routes/users.ts
import { Router, Request, Response } from 'express';
interface CreateUserBody {
name: string;
email: string;
}
interface UserParams {
id: string;
}
const router = Router();
router.post('/', (req: Request<{}, {}, CreateUserBody>, res: Response) => {
const { name, email } = req.body; // fully typed — no any
// TypeScript will error if you access req.body.nonexistent
res.status(201).json({ id: '1', name, email });
});
router.get('/:id', (req: Request<UserParams>, res: Response) => {
const { id } = req.params; // string, guaranteed
res.json({ id, name: 'Esteban', email: 'e@example.com' });
});
export default router;
The Request<Params, ResBody, ReqBody, Query> generic gives you full type coverage across the entire request lifecycle.
Environment variables with type safety
// 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;
}
export const config = {
port: parseInt(process.env.PORT ?? '3000', 10),
dbUrl: requireEnv('DATABASE_URL'),
nodeEnv: (process.env.NODE_ENV ?? 'development') as 'development' | 'production' | 'test',
};
requireEnv fails fast at startup rather than at runtime when the variable is actually used.
Add .gitignore
node_modules/
build/
.env
The build/ folder is generated — never commit it.
Key takeaways
- TypeScript dependencies (
typescript,ts-node,@types/*) aredevDependencies— they don’t ship to production strict: trueis the entire point. Disable it and you’re just writing JavaScript with extra steps- Separate
app.tsfromserver.ts— it makes testing significantly cleaner Request<Params, ResBody, ReqBody, Query>generics are how you get type safety into Express handlersts-nodein development, compiled JavaScript in production — never runts-nodein a prod environment
Once you’ve typed a production Express service, going back to untyped Node feels like working without autocomplete. The setup cost is about 15 minutes; the payoff compounds across every refactor.