Most MEAN teams we meet document their API twice: once in a README that went stale in 2024, and once in the Angular service files where somebody hand-typed the response interfaces. Both copies drift, and the bugs that drift produces — a renamed field, an optional that is actually required, a 404 body that is HTML instead of JSON — surface in production rather than in CI.
The fix is to make one artifact authoritative: an OpenAPI 3.1 document that your Express 5 API generates from the same schemas it validates with, and that your Angular 22 client generates its types and HTTP calls from. This tutorial wires that loop end to end and then locks it with a CI check that fails the build when the contract changes without anyone noticing.
Versions used: Node.js 24 LTS, Express 5, Zod 4, Mongoose 8 / MongoDB 8, Angular 22.
Why OpenAPI 3.1 specifically
3.1 is the version that matters for this workflow, because 3.1 is a strict superset of JSON Schema 2020-12. Before 3.1 you had to down-convert your validation schemas into OpenAPI's own dialect and accept lossy translation (nullable: true instead of type: ["string","null"], no const, no examples arrays). With 3.1 the JSON Schema your validator already uses drops straight into components.schemas with no conversion layer, which is what makes "one source of truth" realistic rather than aspirational.
Step 1: schemas as the single source of truth
Define every request and response shape once, in Zod, next to the route that uses it.
npm install express@5 zod@4 mongoose@8
npm install -D @asteasolutions/zod-to-openapi
api/src/schemas.js:
import { z } from 'zod';
export const ObjectIdString = z.string().regex(/^[a-f\d]{24}$/i).meta({
id: 'ObjectId',
description: 'A 24-character hexadecimal MongoDB ObjectId',
example: '66c0f2f2f2f2f2f2f2f2f2f2',
});
export const TaskCreate = z.object({
title: z.string().min(1).max(200),
done: z.boolean().default(false),
dueAt: z.iso.datetime().nullable().optional(),
}).meta({ id: 'TaskCreate' });
export const Task = TaskCreate.extend({
id: ObjectIdString,
createdAt: z.iso.datetime(),
updatedAt: z.iso.datetime(),
}).meta({ id: 'Task' });
export const TaskList = z.object({
items: z.array(Task),
nextCursor: z.string().nullable(),
}).meta({ id: 'TaskList' });
export const ApiError = z.object({
error: z.object({
code: z.enum(['validation_failed', 'not_found', 'unauthorized', 'conflict']),
message: z.string(),
details: z.array(z.object({ path: z.string(), message: z.string() })).optional(),
}),
}).meta({ id: 'ApiError' });
Note ApiError. A documented error envelope is the half of the contract most teams skip, and it is the half that breaks Angular clients — a front end that expects {error:{message}} and receives Express's default HTML error page will throw inside the subscriber, not in your error interceptor.
Step 2: validate with the same schemas in Express 5
Express 5 finally awaits rejected promises from async handlers, so validation middleware gets simple:
// api/src/validate.js
export const validate = ({ body, query, params }) => (req, res, next) => {
for (const [key, schema] of Object.entries({ body, query, params })) {
if (!schema) continue;
const parsed = schema.safeParse(req[key]);
if (!parsed.success) {
return res.status(400).json({
error: {
code: 'validation_failed',
message: `Invalid request ${key}`,
details: parsed.error.issues.map((i) => ({
path: i.path.join('.'),
message: i.message,
})),
},
});
}
if (key !== 'query') req[key] = parsed.data;
else res.locals.query = parsed.data;
}
next();
};
One Express 5 gotcha worth calling out: req.query is a getter and is no longer writable, so assigning the parsed result back to it throws. Stash it on res.locals (above) or on a custom property instead. This is one of the most common breakages we see when clients move an API from Express 4 to 5 with validation middleware in place.
Routes then reference the schemas directly:
// api/src/routes/tasks.js
import { Router } from 'express';
import { TaskCreate } from '../schemas.js';
import { validate } from '../validate.js';
import { Task as TaskModel } from '../models.js';
export const tasks = Router();
tasks.post('/tasks', validate({ body: TaskCreate }), async (req, res) => {
const doc = await TaskModel.create({ ...req.body, owner: req.user.id });
res.status(201).json(serialize(doc));
});
Step 3: generate the OpenAPI document
Register each operation once, in a registry that reads the same schema objects:
// api/src/openapi.js
import { OpenAPIRegistry, OpenApiGeneratorV31 } from '@asteasolutions/zod-to-openapi';
import { Task, TaskCreate, TaskList, ApiError } from './schemas.js';
const registry = new OpenAPIRegistry();
const bearer = registry.registerComponent('securitySchemes', 'bearerAuth', {
type: 'http', scheme: 'bearer', bearerFormat: 'JWT',
});
registry.registerPath({
method: 'post',
path: '/api/tasks',
operationId: 'createTask',
tags: ['Tasks'],
security: [{ [bearer.name]: [] }],
request: { body: { content: { 'application/json': { schema: TaskCreate } } } },
responses: {
201: { description: 'Created', content: { 'application/json': { schema: Task } } },
400: { description: 'Validation failed', content: { 'application/json': { schema: ApiError } } },
401: { description: 'Unauthorized', content: { 'application/json': { schema: ApiError } } },
},
});
export function buildDocument() {
return new OpenApiGeneratorV31(registry.definitions).generateDocument({
openapi: '3.1.0',
info: { title: 'Tasks API', version: process.env.npm_package_version ?? '1.0.0' },
servers: [{ url: 'https://api.example.com' }],
});
}
Write it to disk with a script — node --experimental-strip-types or plain JS, either way it is a one-liner:
// api/scripts/emit-openapi.js
import { writeFileSync } from 'node:fs';
import { buildDocument } from '../src/openapi.js';
writeFileSync('openapi.json', JSON.stringify(buildDocument(), null, 2) + '\n');
node api/scripts/emit-openapi.js
operationId is not decoration: it becomes the method name in the generated Angular client. Name operations the way you want to call them (createTask, listTasks, getTaskById), and keep them stable, because renaming one is a breaking change for every consumer.
Step 4: generate a typed Angular 22 client
npm install -D @hey-api/openapi-ts
npx @hey-api/openapi-ts \
--input ../api/openapi.json \
--output src/app/api \
--plugins @hey-api/client-fetch @hey-api/typescript @hey-api/sdk
The output is a folder of generated TypeScript you commit but never edit. Add it to .prettierignore and mark it in CODEOWNERS so review comments land on the schema, not on the artifact.
Wire the generated client's base URL and auth once, then let components call it inside httpResource or a signal-based service:
// src/app/api-config.ts
import { client } from './api/client.gen';
import { inject } from '@angular/core';
import { TokenStore } from './token-store';
export function configureApiClient() {
const tokens = inject(TokenStore);
client.setConfig({ baseUrl: '/api' });
client.interceptors.request.use((request) => {
const token = tokens.accessToken();
if (token) request.headers.set('Authorization', `Bearer ${token}`);
return request;
});
}
// src/app/tasks/task-store.ts
import { Injectable, signal } from '@angular/core';
import { createTask, listTasks } from '../api';
import type { Task } from '../api/types.gen';
@Injectable({ providedIn: 'root' })
export class TaskStore {
readonly tasks = signal<Task[]>([]);
async load() {
const { data, error } = await listTasks({ query: { limit: 50 } });
if (error) throw new Error(error.error.message);
this.tasks.set(data.items);
}
async add(title: string) {
const { data, error } = await createTask({ body: { title, done: false } });
if (error) throw new Error(error.error.message);
this.tasks.update((list) => [data, ...list]);
}
}
Now delete every hand-written interface in src/app/**/models.ts. If the compiler complains, that is the point: those complaints are the drift you have been shipping.
Step 5: fail CI when the contract drifts
Two checks, both cheap.
5a. The committed spec matches the code.
# .github/workflows/api-contract.yml
- run: node api/scripts/emit-openapi.js
- run: git diff --exit-code openapi.json
# fails if a schema changed without regenerating the spec
5b. The change is not a silent breaking change. Diff the spec against the base branch with oasdiff:
npx oasdiff breaking origin/main:openapi.json openapi.json --fail-on ERR
That catches removed fields, narrowed enums, newly required request properties and dropped response codes — the four things that break an Angular client at runtime with a green test suite. Teams that ship an API to more than one consumer should also publish the spec as a versioned artifact so consumers can pin it.
Optionally serve the spec from the API itself for humans:
app.get('/openapi.json', (req, res) => res.json(buildDocument()));
Serve the JSON and point a static docs renderer at it; do not mount a docs UI that bundles its own copy of the spec, or you have created a third source of truth.
What this buys a MEAN team
- One definition per shape. Validation, documentation and client types all derive from the Zod schema. There is no second place to update.
- Breaking changes fail in CI, not in the browser.
oasdiffturns "we renamed a field" from an incident into a pull-request comment. - Onboarding drops from days to hours. A new front-end contributor gets autocomplete over the whole API instead of reading route handlers.
- The spec is portable. Mock servers, contract tests, Postman collections and API gateways all consume OpenAPI 3.1 directly.
The migration into an existing codebase does not have to be a big bang. Start with one router: define its schemas, generate the spec for those paths only, generate the client, and delete the matching hand-written interfaces. Repeat per router. Most of the APIs we take over reach full coverage in two or three sprints of background work.
If you are staring at a large Express API with no contract at all — or an Express 4 codebase where this validation middleware will not port cleanly — get in touch. Contract-first API work is one of the things our MEAN Stack API consulting engagements do first, because everything else gets cheaper once the contract is real.