Build Type‑Safe APIs with TypeScript: A Complete Guide
Learn how to design, implement, and test type‑safe APIs using TypeScript. Step‑by‑step examples, best practices, and tooling tips for robust, maintainable back‑ends.
Introduction
When you build a modern web service, the biggest source of bugs is often a mismatch between the data you think you are sending and the data the client actually receives. JavaScript’s dynamic nature makes it easy to overlook subtle shape changes, leading to runtime errors that are hard to trace. TypeScript solves this problem by bringing static typing to the JavaScript ecosystem, allowing you to declare the exact contract of your API and have the compiler enforce it.
In this guide we’ll walk through the entire lifecycle of a type‑safe API built with TypeScript:
Defining shared types with interfaces and type aliases.
Using type‑safe routing with Express and Zod for runtime validation.
Generating client SDKs automatically with openapi‑typegen.
Testing the contract with Jest and SuperTest.
Deploying with Docker while preserving type safety.
By the end you’ll have a production‑ready API that catches mismatches at compile time, validates payloads at runtime, and provides a typed client for front‑end developers.
Why Type Safety Matters for APIs
Early error detection – TypeScript flags mismatched request/response shapes during development, not after a user reports a bug.
Self‑documenting code – Interfaces act as living documentation that IDEs can surface instantly.
Improved refactoring – Renaming a field in a shared type propagates automatically across server and client.
Better onboarding – New team members can explore the API contract through type definitions rather than reading separate docs.
Even though TypeScript provides compile‑time guarantees, an API still receives data from the outside world. That’s why we combine static typing with runtime validation—a pattern often called type‑first development.
1. Setting Up the Project
# Create a new folder and initialise npm
mkdir ts-api && cd ts-api
npm init -y
# Install core dependencies
npm i express zod
npm i -D typescript ts-node-dev @types/express @types/node
# Initialise TypeScript config
npx tsc --init --rootDir src --outDir dist --esModuleInterop
Your tsconfig.json should enable strict mode for maximum safety:
Create a src/types.ts file that contains the canonical shapes of your API objects. These types will be imported both by the server (for request validation) and by any generated client SDK.
Tip: Keep this file tiny and focused. If your domain grows, consider a folder like src/schemas and split by feature.
3. Runtime Validation with Zod
Static types disappear after compilation, so we must validate incoming JSON. Zod lets us derive a runtime schema directly from TypeScript types, ensuring the two stay in sync.
// src/validation.ts
import { z } from 'zod';
import { CreateUserDto, UpdateUserDto } from './types';
export const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
password: z.string().min(6),
}) as z.ZodType<CreateUserDto>;
export const updateUserSchema = z.object({
name: z.string().optional(),
email: z.string().email().optional(),
password: z.string().min(6).optional(),
}) as z.ZodType<UpdateUserDto>;
The as z.ZodType<...> cast tells TypeScript that the schema’s output matches the DTO interface, keeping the two layers aligned.
4. Building an Express Router with Types
// src/routes/user.ts
import { Router, Request, Response } from 'express';
import { User, CreateUserDto, UpdateUserDto } from '../types';
import { createUserSchema, updateUserSchema } from '../validation';
import { z } from 'zod';
const router = Router();
// In‑memory store for demo purposes
const users = new Map<string, User>();
function validate<T>(schema: z.ZodSchema<T>) {
return (req: Request, res: Response, next: Function) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.format() });
}
(req as any).validatedBody = result.data as T;
next();
};
}
router.get('/', (req, res) => {
const all = Array.from(users.values());
res.json(all);
});
router.post(
'/',
validate<CreateUserDto>(createUserSchema),
(req, res) => {
const data = (req as any).validatedBody as CreateUserDto;
const id = `${Date.now()}`;
const newUser: User = { id, role: 'user', ...data };
users.set(id, newUser);
res.status(201).json(newUser);
}
);
router.patch(
'/:id',
validate<UpdateUserDto>(updateUserSchema),
(req, res) => {
const { id } = req.params;
const existing = users.get(id);
if (!existing) return res.sendStatus(404);
const updates = (req as any).validatedBody as UpdateUserDto;
const updated = { ...existing, ...updates };
users.set(id, updated);
res.json(updated);
}
);
export default router;
Notice how the request body is strongly typed (CreateUserDto, UpdateUserDto) after validation, eliminating any usage.
5. Main Application Entry Point
// src/index.ts
import express from 'express';
import userRouter from './routes/user';
import bodyParser from 'body-parser';
const app = express();
app.use(bodyParser.json());
app.use('/api/users', userRouter);
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`🚀 Server listening on http://localhost:${PORT}`);
});
Run the dev server with:
npx ts-node-dev src/index.ts
You now have a fully type‑checked API that also validates payloads at runtime.
6. Generating a Typed Client SDK
Manually writing client code duplicates type definitions and introduces drift. Tools like openapi‑typegen can generate a TypeScript client from an OpenAPI spec that we derive from our code.
6.1 Export an OpenAPI Spec
Install express-oas-generator:
npm i express-oas-generator
Add it to src/index.ts before routes are registered:
import oas from 'express-oas-generator';
...
oas.handleResponses(app, {});
Start the server once, hit each endpoint, then stop. The generator will create openapi.json in the project root.
6.2 Generate the Client
npm i -D openapi-typegen
npx openapi-typegen ./openapi.json ./src/api-client.ts
The resulting api-client.ts contains fully typed functions:
Now your front‑end can import api-client.ts and get compile‑time safety without any manual DTO duplication.
7. Testing the Contract with Jest & SuperTest
npm i -D jest ts-jest supertest @types/jest @types/supertest
npx jest --init
Create src/__tests__/user.test.ts:
import request from 'supertest';
import app from '../index'; // export the Express instance from index.ts
import { User } from '../types';
describe('User API', () => {
let created: User;
it('creates a user', async () => {
const res = await request(app)
.post('/api/users')
.send({ name: 'Alice', email: 'alice@example.com', password: 'secret123' })
.expect(201);
created = res.body as User;
expect(created.id).toBeDefined();
expect(created.role).toBe('user');
});
it('fetches all users', async () => {
const res = await request(app).get('/api/users').expect(200);
const list = res.body as User[];
expect(list).toContainEqual(created);
});
it('updates a user partially', async () => {
const res = await request(app)
.patch(`/api/users/${created.id}`)
.send({ name: 'Alice Updated' })
.expect(200);
expect(res.body.name).toBe('Alice Updated');
});
});
Run npm test. The tests will fail at compile time if the response type no longer matches User, giving you a safety net beyond runtime validation.
8. Dockerising the Type‑Safe API
Create a lightweight Docker image that compiles TypeScript before runtime. This ensures the compiled JavaScript matches the type‑checked source.
# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json tsconfig.json ./
COPY src ./src
RUN npm ci && npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY package*.json ./
RUN npm ci --production
EXPOSE 3000
CMD ["node", "dist/index.js"]
Add a build script to package.json:
"scripts": { "build": "tsc" }
Build and run:
docker build -t ts-api .
docker run -p 3000:3000 ts-api
The container runs the compiled, type‑safe code, guaranteeing that no type errors slipped into production.
9. Best Practices Checklist
✅
Practice
1
Keep shared types in a dedicated module imported by both server and client generators.
2
Use Zod (or Yup, Joi) to mirror DTOs at runtime; cast schemas to ZodType<T> for compile‑time alignment.
3
Generate OpenAPI spec automatically to avoid manual documentation drift.
4
Auto‑generate a typed client SDK; ship it as an npm package for front‑end teams.
5
Write integration tests that assert response shapes against TypeScript interfaces.
6
Enable strict mode (strict: true) in tsconfig.json for the strongest guarantees.
7
Include linting (eslint, prettier) to keep code style consistent and catch hidden anys.
8
Use Docker multi‑stage builds to compile before runtime, ensuring the binary matches the source.
Conclusion
Building a type‑safe API with TypeScript is more than just renaming .js files to .ts. It requires a type‑first mindset: define contracts once, validate them both at compile time and at the network boundary, and let tooling generate the glue code that keeps client and server in sync. By combining TypeScript’s static analysis, Zod’s runtime schemas, OpenAPI generation, and automated tests, you eliminate a large class of bugs, improve developer velocity, and deliver a clear, maintainable contract for every consumer of your service.
Start by refactoring an existing endpoint to use the patterns above, and you’ll quickly feel the safety net that TypeScript provides. Happy coding!