Setting Up a TypeScript Project
Why It Matters
TypeScript adds static types to JavaScript. In Node.js backends, this helps catch mistakes before runtime, documents function contracts, improves editor autocomplete, and makes refactoring safer as the codebase grows.
TypeScript does not make a program correct by itself. It cannot prove that a database row exists, that a JSON request body is valid, or that an environment variable is configured. It does, however, make many everyday errors visible earlier: misspelled properties, wrong argument types, forgotten null checks, and mismatched return shapes.
A good setup matters because TypeScript touches build scripts, module format, package metadata, test tooling, linting, runtime imports, and deployment output.
Core Concepts
TypeScript Source vs JavaScript Output
Node.js runs JavaScript. TypeScript files such as src/server.ts must either be compiled to JavaScript or run through a development tool that transpiles them.
Common layout:
my-api/
src/
server.ts
app.ts
dist/
server.js
app.js
package.json
tsconfig.jsonsrc is authored by developers. dist is generated and deployed.
Compiler
The TypeScript compiler, tsc, checks types and can emit JavaScript.
npm install --save-dev typescript
npx tsc --initYou can run:
npx tsc --noEmitto type-check without writing output files.
Runtime Tooling
During development, many teams use a runner such as tsx to run TypeScript directly:
npm install --save-dev tsxThen:
npx tsx src/server.tsFor production, compile with tsc and run the generated JavaScript with Node.
ESM and CommonJS
Modern Node supports ESM with import and export. In a new project, prefer ESM unless your dependencies or platform require CommonJS.
package.json:
{
"type": "module"
}ESM imports should include file extensions in emitted JavaScript. With moduleResolution set to NodeNext, TypeScript expects extension-aware imports:
import { createApp } from './app.js';Even though the source file is app.ts, the import points to the JavaScript file that will exist after compilation.
Syntax and Examples
Create a Project
mkdir node-ts-api
cd node-ts-api
npm init -y
npm install express
npm install --save-dev typescript tsx @types/node @types/expressPackage Scripts
{
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"typecheck": "tsc --noEmit",
"build": "tsc",
"start": "node dist/server.js"
}
}These scripts separate development, type-checking, build, and production runtime.
A Practical tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"sourceMap": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}Important options:
strict: enables the main family of type safety checks.rootDir: where TypeScript source files live.outDir: where compiled JavaScript is written.moduleandmoduleResolution: align TypeScript with Node ESM behavior.sourceMap: helps stack traces map back to TypeScript source.noUncheckedIndexedAccess: makes array and object indexing safer.exactOptionalPropertyTypes: makes optional fields more precise.
Minimal Express App
src/app.ts:
import express, { type Request, type Response } from 'express';
export function createApp() {
const app = express();
app.use(express.json({ limit: '100kb' }));
app.get('/health', (_req: Request, res: Response) => {
res.json({ status: 'ok' });
});
return app;
}src/server.ts:
import { createServer } from 'node:http';
import { createApp } from './app.js';
const port = Number(process.env.PORT || 3000);
const app = createApp();
const server = createServer(app);
server.listen(port, () => {
console.log(`Server listening on port ${port}`);
});Notice the node:http built-in specifier and the .js extension in the local ESM import.
Type-Only Imports
Use type imports when a value is only needed for checking.
import type { Request, Response, NextFunction } from 'express';
export function errorHandler(
error: unknown,
_req: Request,
res: Response,
_next: NextFunction
) {
console.error(error);
res.status(500).json({ error: 'Internal server error' });
}Type-only imports can be removed from emitted JavaScript, which avoids runtime import confusion.
Use Cases
TypeScript is especially helpful in Node.js apps for:
- Route handlers with typed params, request bodies, and response shapes.
- Service layers with explicit inputs and outputs.
- Database model and DTO definitions.
- Configuration parsing and environment variable validation.
- Refactoring large codebases.
- Shared types between backend and frontend packages.
- Safer test doubles and mocks.
It is less useful if the project is a tiny script that will not be maintained, though even scripts can benefit from editor support and type-checking.
Tradeoffs
TypeScript adds a build step and some configuration complexity. Developers need to understand type errors, module resolution, and differences between source imports and runtime imports. Some libraries have incomplete or inaccurate types, and strict settings can slow down initial migration.
The benefits grow with codebase size and team size. A small app may feel slower to set up in TypeScript, but a large app is much easier to maintain when contracts are visible in code.
Production Implications
Compile Before Deploying
Prefer deploying compiled JavaScript from dist. This keeps production startup simple:
npm run build
npm startDo Not Rely on TypeScript for Runtime Validation
Types disappear at runtime. If an HTTP client sends invalid JSON, TypeScript cannot stop it. Use runtime validation for external inputs.
function parsePort(value: string | undefined): number {
const port = Number(value || 3000);
if (!Number.isInteger(port) || port <= 0) {
throw new Error('PORT must be a positive integer');
}
return port;
}Source Maps
Source maps help connect production stack traces to TypeScript source. Enable them with sourceMap: true, but understand how your logging and deployment platform handles them.
Common Mistakes
- Disabling
strictto avoid learning type errors. - Using
anywhenever the compiler complains. - Forgetting
.jsextensions in ESM local imports. - Running TypeScript directly in production without a clear reason.
- Assuming TypeScript validates request bodies at runtime.
- Committing generated
distfiles when the project convention excludes them. - Mixing CommonJS and ESM without understanding package boundaries.
- Installing runtime dependencies as dev dependencies or type packages as production dependencies.
Practical Challenge
Create a TypeScript Express project:
- Configure
package.jsonfor ESM. - Add
dev,typecheck,build, andstartscripts. - Create a strict
tsconfig.json. - Add
src/app.tswith a/healthroute. - Add
src/server.tsthat usesnode:http. - Run
npm run typecheck,npm run build, andnpm start.
Then intentionally introduce a type error, confirm tsc catches it, and fix it.
Recap
A strong TypeScript setup gives Node.js projects safer contracts and better refactoring support. Use strict compiler settings, separate source from build output, prefer ESM for new projects, use node: specifiers for built-ins, and remember that TypeScript checks code at build time while runtime validation still protects external boundaries.