Documentation as Code — Keeping API Docs Accurate and Up to Date
Advertisement
Introduction
Why This Matters
API documentation written by hand in Confluence or Notion drifts from reality immediately. A field gets renamed, an endpoint gets a new required parameter, a response shape changes — and the docs say nothing. Teams lose time to support requests, integration bugs, and onboarding friction that comes from stale documentation. Documentation-as-code eliminates the drift by generating docs from the code that actually runs.
OpenAPI Generation from TypeScript Types
The most reliable API docs are generated from the same types that enforce your request/response contracts at runtime.
// src/api/products.ts — Zod schema drives both validation and OpenAPI spec
import { z } from 'zod';
import { extendZodWithOpenApi } from 'zod-openapi';
import { Router } from 'express';
extendZodWithOpenApi(z);
// Schema is the single source of truth for both validation and docs
const CreateProductSchema = z.object({
name: z.string().min(1).max(255).openapi({ description: 'Product display name', example: 'Wireless Headphones' }),
price: z.number().positive().openapi({ description: 'Price in USD cents', example: 9999 }),
sku: z.string().min(1).openapi({ description: 'Stock keeping unit', example: 'WH-1000XM5' }),
description: z.string().optional().openapi({ description: 'Optional product description' }),
}).openapi({ ref: 'CreateProduct' });
const ProductResponseSchema = z.object({
id: z.string().uuid(),
name: z.string(),
price: z.number(),
sku: z.string(),
description: z.string().nullable(),
createdAt: z.string().datetime(),
}).openapi({ ref: 'Product' });
// Route handler — the schema is the contract
const router = Router();
router.post('/products', async (req, res) => {
const result = CreateProductSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.flatten() });
}
const product = await createProduct(result.data);
return res.status(201).json(ProductResponseSchema.parse(product));
});
export { CreateProductSchema, ProductResponseSchema };// src/openapi/generate.ts — generate spec from schemas
import { createDocument } from 'zod-openapi';
import { CreateProductSchema, ProductResponseSchema } from '../api/products';
import * as fs from 'fs';
import * as yaml from 'js-yaml';
const document = createDocument({
openapi: '3.1.0',
info: {
title: 'Product API',
version: '1.0.0',
description: 'Generated from TypeScript Zod schemas — always accurate',
},
paths: {
'/products': {
post: {
operationId: 'createProduct',
summary: 'Create a new product',
tags: ['Products'],
requestBody: {
required: true,
content: {
'application/json': { schema: CreateProductSchema },
},
},
responses: {
'201': {
description: 'Product created',
content: {
'application/json': { schema: ProductResponseSchema },
},
},
'400': { description: 'Validation error' },
},
},
},
},
});
// Write to static file — committed to repo, consumed by Stoplight/Swagger UI
const spec = yaml.dump(document);
fs.writeFileSync('./openapi.yaml', spec);
console.log('OpenAPI spec generated');Typedoc for Internal APIs
TypeDoc generates HTML documentation directly from TypeScript JSDoc comments — no separate documentation step required.
// src/services/payment.service.ts
/**
* Processes a payment for an order.
*
* @param orderId - The UUID of the order to pay for
* @param paymentMethod - Stripe payment method ID (pm_xxx)
* @param amount - Amount in USD cents (must be positive integer)
* @returns PaymentResult with Stripe charge ID and status
* @throws {PaymentDeclinedError} When the card is declined
* @throws {OrderNotFoundError} When orderId does not exist
*
* @example
* ```typescript
* const result = await paymentService.processPayment(
* 'order-uuid',
* 'pm_1234567890',
* 9999
* );
* // result.chargeId: 'ch_xxx'
* // result.status: 'succeeded'
* ```
*/
export async function processPayment(
orderId: string,
paymentMethod: string,
amount: number
): Promise<PaymentResult> {
// implementation
}
/**
* Represents the result of a successful payment operation.
*/
export interface PaymentResult {
/** Stripe charge ID */
chargeId: string;
/** Payment status from Stripe */
status: 'succeeded' | 'pending' | 'failed';
/** ISO timestamp of charge creation */
processedAt: string;
}// typedoc.json
{
"entryPoints": ["src/services", "src/api"],
"out": "docs/api",
"excludePrivate": true,
"excludeInternal": true,
"includeVersion": true,
"readme": "README.md",
"plugin": ["typedoc-plugin-markdown"]
}ADR (Architecture Decision Records) as Code
Architecture decisions should live in the repository alongside the code they govern.
# docs/adr/001-use-postgresql-advisory-locks.md
# ADR 001: Use PostgreSQL Advisory Locks for Job Deduplication
## Status
Accepted (2026-02-15)
## Context
Our job queue processes payment events that must not be processed twice.
We need distributed locking across multiple API server instances.
## Decision
Use PostgreSQL advisory locks (`pg_try_advisory_xact_lock`) rather than Redis.
## Consequences
**Positive:**
- No additional infrastructure (already on PostgreSQL)
- Locks auto-release on transaction commit/rollback
- Participates in database transaction — no partial states
**Negative:**
- Couples job locking to database availability
- Lock key must be a 64-bit integer (requires hashing string IDs)
## Alternatives Considered
- Redis SET NX: Requires separate infrastructure, TTL management
- Redlock: Operational complexity for a problem Postgres solves natively# Install adr-tools for managing ADR lifecycle
npm install -g adr-tools
# Create new ADR
adr new "Use PostgreSQL for advisory locks"
# List all decisions
adr list
# Link related decisions
adr link 2 "is superseded by" 5Automated Doc Generation in CI
# .github/workflows/docs.yml
name: Generate and Deploy Docs
on:
push:
branches: [main]
jobs:
generate-docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm ci
# Generate OpenAPI spec from Zod schemas
- name: Generate OpenAPI spec
run: npm run generate:openapi
# Check spec hasn't changed without a commit
- name: Verify spec is committed
run: |
if git diff --name-only | grep -q 'openapi.yaml'; then
echo "ERROR: openapi.yaml is out of date. Run 'npm run generate:openapi' and commit."
exit 1
fi
# Generate TypeDoc HTML
- name: Generate TypeDoc
run: npm run docs:generate
# Deploy to GitHub Pages
- name: Deploy to Pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs/api
# Comment on PR with link to preview
- name: Comment PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'API docs preview: https://org.github.io/repo/api'
})Common Mistakes
- Writing docs in Confluence/Notion separately from code — they drift immediately after the first release
- Not generating OpenAPI specs from the validation schemas — two sources of truth diverge
- Committing generated docs but not the generator — makes the output untrustworthy
- Not checking in CI that generated specs match committed specs — docs get stale silently
- JSDoc comments that describe implementation, not contract — describe what callers need, not how it works
- ADRs that document only the decision, not the rejected alternatives — future engineers need the tradeoffs
Best Practices
- Generate OpenAPI specs from the same Zod/TypeBox schemas that validate requests at runtime
- Block merges in CI when the committed spec does not match what the generator produces
- Use TypeDoc for internal service APIs — JSDoc in source is documentation that never drifts
- Write ADRs for every non-obvious architectural decision and commit them to the repo
- Publish docs automatically on merge to main via GitHub Actions
- Include runnable code examples in JSDoc — they get tested, prose does not
Key Takeaways
- Documentation written separately from code drifts from reality within days of any change
- Generating OpenAPI specs from Zod or TypeBox schemas produces docs that are correct by construction
- CI should fail when committed spec files do not match what the generator produces from current source
- TypeDoc converts JSDoc comments into browsable API documentation with zero extra tooling
- Architecture Decision Records (ADRs) in the repository give future engineers context for non-obvious choices
- Documentation-as-code means docs are reviewed in PRs alongside the code they describe
- The best documentation is runnable: examples in tests and type signatures in source are always accurate
- Generated docs on GitHub Pages with automatic CI deployment keep documentation URL stable and current
Advertisement