Every project has that one wiki page. You know the one — last edited 14 months ago, still referencing the v2 API that got rewritten in v3, and confidently documenting parameters that no longer exist. Someone added a @deprecated comment to the source six months ago and nobody noticed.
This is the fundamental problem with documentation that lives anywhere except the code itself: it drifts. The code moves, the docs don’t, and eventually the docs become actively harmful — they send developers down dead ends and erode trust in the whole documentation system.
TypeScript gives us a real fix for this. The combination of TSDoc (a specification for writing structured doc comments in TypeScript) and TypeDoc (a tool that turns those comments into a full HTML documentation site) means your docs are generated from the source on every build. If the function signature changes, the docs update automatically. If a parameter disappears, it disappears from the docs too.
This article covers the full setup: how to write TSDoc comments correctly, how to configure TypeDoc to generate a clean site, how to integrate it into CI/CD, and the gotchas that will bite you if you skip to the end.
The official repositories: TSDoc on GitHub and TypeDoc on GitHub.
What TSDoc Actually Is (And What It Isn’t)
TSDoc is a specification, not a tool. It defines a standard syntax for writing doc comments in TypeScript files — the /** ... */ blocks above your functions, classes, and interfaces. Microsoft created it to solve a real problem: JSDoc was designed for JavaScript and its tags don’t map cleanly onto TypeScript’s type system. Different tools (TypeDoc, API Extractor, VS Code’s IntelliSense) were parsing these comments inconsistently and producing different results from the same source.
TSDoc standardizes the format. The canonical tags, their meaning, and their expected parsing behavior are all specified. This means a comment written to the TSDoc spec will render consistently whether you’re viewing it in your editor, generating a site with TypeDoc, or extracting an API report with API Extractor.
TypeDoc is the tool that does the actual work. It parses your TypeScript source, extracts the TSDoc comments, resolves all the types (using the TypeScript compiler API directly), and outputs a documentation site. Because it uses the real TypeScript compiler, the types in the docs are always accurate — they’re the same types the compiler sees.
Installation
You need two packages:
npm install --save-dev typedoc
TypeDoc has no peer dependency on a separate TSDoc parser package for basic use. It ships with its own comment parser. If you want the stricter TSDoc-compliant parser (which rejects malformed comments instead of silently ignoring them), install the plugin:
npm install --save-dev typedoc-plugin-dt-links
Actually, for strict TSDoc mode you want:
npm install --save-dev @microsoft/tsdoc
But for most projects, the stock TypeDoc comment parser is fine and follows TSDoc conventions closely enough.
Writing TSDoc Comments
Before generating anything, you need something worth generating. Here’s how to write TSDoc comments that produce useful output.
The Basics
/**
* Calculates the compound interest for a principal over time.
*
* @param principal - The initial amount in the account.
* @param rate - Annual interest rate as a decimal (e.g., 0.05 for 5%).
* @param years - Number of years to compound.
* @returns The total amount after compounding.
*
* @example
* ```typescript
* const total = compoundInterest(1000, 0.05, 10);
* console.log(total); // 1628.89
* ```
*/
export function compoundInterest(
principal: number,
rate: number,
years: number
): number {
return principal * Math.pow(1 + rate, years);
}
A few things to notice: @param tags include the parameter name followed by a dash, then the description. The dash is part of the TSDoc spec — it’s not decorative. TypeDoc will parse it correctly with or without the dash, but tooling like API Extractor will complain if it’s missing.
Documenting Interfaces and Types
TypeDoc renders interface members as their own documentation entries. Document the interface itself and each property:
/**
* Configuration for the HTTP client.
*
* @remarks
* All timeout values are in milliseconds. Setting a timeout to 0 disables it,
* which is almost never what you want in production.
*/
export interface HttpClientConfig {
/** Base URL for all requests. Must include the protocol. */
baseUrl: string;
/**
* Request timeout in milliseconds.
* @defaultValue 5000
*/
timeout?: number;
/**
* Number of retry attempts before giving up.
* @defaultValue 3
*/
retries?: number;
}
Inline single-line comments (/** ... */) on properties are valid TSDoc and TypeDoc handles them well. Use them for short descriptions; switch to the full multi-line block when you need @remarks, @defaultValue, or examples.
Marking Deprecated APIs
This is where the sync-with-code story pays off. When you deprecate a function:
/**
* Fetches a user by their username.
*
* @deprecated Use {@link getUserById} instead. Will be removed in v4.0.
* @param username - The username to look up.
*/
export async function getUserByUsername(username: string): Promise<User> {
// legacy implementation
}
TypeDoc will render this with a visible deprecation warning in the generated site. Your editor’s IntelliSense will show the strikethrough. The same annotation does both jobs with zero duplication.
Cross-References with {@link}
/**
* Creates a new session for the user.
*
* @param user - The authenticated user. See {@link User} for the full shape.
* @returns A {@link Session} object. Pass this to {@link destroySession} when done.
*/
export function createSession(user: User): Session {
// ...
}
{@link} tags create hyperlinks in the generated documentation. They resolve against your actual exported types — if you rename User to AuthenticatedUser and forget to update the link, TypeDoc will warn you.
Configuring TypeDoc
TypeDoc accepts configuration via a typedoc.json file in your project root (or via the typedoc key in package.json). A standalone config file is cleaner for anything beyond trivial projects.
{
"$schema": "https://typedoc.org/schema.json",
"entryPoints": ["src/index.ts"],
"out": "docs",
"tsconfig": "tsconfig.json",
"name": "My Library",
"readme": "README.md",
"includeVersion": true,
"excludePrivate": true,
"excludeProtected": false,
"excludeInternal": true,
"categorizeByGroup": true,
"sort": ["source-order"],
"validation": {
"notExported": true,
"invalidLink": true,
"notDocumented": false
}
}
Walk through the important parts:
entryPoints — TypeDoc starts here and follows your exports. If you have a library with multiple entry points (e.g., src/client/index.ts and src/server/index.ts), you can pass an array and set "entryPointStrategy": "expand" or "packages" depending on your setup.
excludePrivate and excludeInternal — private class members are excluded by default, which is correct. @internal is a TSDoc tag you add to things that are exported for technical reasons but aren’t part of the public API. Mark internal utilities with /** @internal */ and TypeDoc won’t document them.
validation.notDocumented — I’ve set this to false here, but flip it to true on a mature project. It makes TypeDoc warn (or error, with --failOnWarnings) on any exported symbol without a doc comment. That’s the ratchet that prevents documentation debt from accumulating.
validation.invalidLink — always true. Broken {@link} references are bugs.
Multiple Entry Points (Monorepo / Package Setup)
For a project that exposes multiple public packages:
{
"$schema": "https://typedoc.org/schema.json",
"entryPointStrategy": "packages",
"entryPoints": ["packages/core", "packages/cli", "packages/react"],
"out": "docs"
}
Each package directory needs its own package.json with a main or exports field. TypeDoc will generate a unified site with separate sections per package. This is the cleanest way to handle monorepos.
Adding TypeDoc to Your Build Pipeline
package.json Scripts
{
"scripts": {
"docs": "typedoc",
"docs:watch": "typedoc --watch",
"docs:strict": "typedoc --failOnWarnings"
}
}
--watch is useful during development — TypeDoc re-generates the site on file changes. --failOnWarnings is what you use in CI; it turns any documentation warning into a non-zero exit code, which fails the build.
GitHub Actions
# .github/workflows/docs.yml
name: Generate Documentation
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
docs:
runs-on: ubuntu-latest
permissions:
contents: write # needed for GitHub Pages deploy
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Build docs (strict mode)
run: npm run docs:strict
# Deploy to GitHub Pages on main branch pushes only
- name: Deploy to GitHub Pages
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs
The PR check runs docs:strict to catch broken links and undocumented exports before merge. The deploy step only triggers on actual pushes to main, so PRs don’t attempt to publish.
Gotchas
Re-exports Don’t Always Work as Expected
If your src/index.ts re-exports from sub-modules using barrel patterns, TypeDoc may or may not follow the chain correctly depending on how TypeScript resolves the types. The safest pattern:
// src/index.ts — explicit re-exports work reliably
export { UserService } from './services/user';
export type { User, UserCreateInput } from './types/user';
Star re-exports (export * from './services/user') work in most cases, but they can produce unexpected results when you have name collisions across sub-modules. Be explicit in your barrel files.
The @internal Tag Requires Configuration
@internal only excludes symbols if excludeInternal: true is set in typedoc.json. Without that flag, @internal comments are silently ignored and the symbols show up in the docs. This surprises people who tag internal utilities and then wonder why they’re still published.
TypeDoc Doesn’t Automatically Infer Descriptions from Type Names
TypeScript’s type system is self-documenting to a degree — a parameter named userId: string is clear. But TypeDoc will happily generate a page with "No description provided." next to it. On a library that other people will use, that’s a failure. Enable validation.notDocumented: true in strict mode.
Generics Need Manual Documentation
TypeDoc renders generic type parameters, but it doesn’t know what they represent semantically. Document them with @typeParam:
/**
* A result type that explicitly encodes success or failure.
*
* @typeParam T - The value type on success.
* @typeParam E - The error type on failure. Defaults to `Error`.
*/
export type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
Watch Mode Misses Some Changes
typedoc --watch uses TypeScript’s incremental compilation. It will miss changes to files that aren’t TypeScript (your typedoc.json, README.md, or assets). Restart the watcher after config changes.
Version Numbers in the Docs Title
"includeVersion": true in typedoc.json appends the version from package.json to the site title. This is great for versioned library docs, but it means every version bump generates a diff in your docs output. If you’re committing generated docs to the repo (generally not recommended — let CI publish them), this creates noise.
Production-Ready Patterns
Don’t commit the generated docs/ folder. Generate it in CI and publish to GitHub Pages, Netlify, or an S3 bucket. Add docs/ to .gitignore. Source-controlled generated files cause merge conflicts and inflate your git history.
Use @packageDocumentation at the top of your main entry file:
/**
* A zero-dependency HTTP client for Node.js and the browser.
*
* @remarks
* Install with `npm install my-http-client`.
* See the {@link HttpClientConfig} interface for all configuration options.
*
* @packageDocumentation
*/
This text appears on the front page of the generated docs. Without it, TypeDoc generates a mostly empty index page.
Set up the @category tag to organize large APIs:
/**
* Builds a query string from an object.
*
* @category Utilities
*/
export function buildQueryString(params: Record<string, string>): string {
// ...
}
With categorizeByGroup: true in your config, TypeDoc groups the sidebar by category, which is far more navigable than a flat alphabetical list once you have more than 20 exports.
Run TypeDoc as part of your release checklist, not as an afterthought. A script that bumps the version, runs tests, generates docs, and publishes will catch documentation breakage before it ships.
The Bigger Picture
The argument for TSDoc + TypeDoc isn’t really about beautiful documentation sites, though those are nice. It’s about the discipline that comes with treating documentation as a first-class artifact of the build.
When broken {@link} references fail CI, developers fix them. When notDocumented warnings block merges, developers write the doc comments. The process enforces completeness at the point of authorship, which is the only moment when the author actually knows what the code does.
A wiki or a Confluence page requires a separate act of will to update. A /** */ block above a function is three keystrokes away from the code it describes. That proximity is the whole trick.
Set up the CI gate. Enable --failOnWarnings. The first week feels like overhead; after that it becomes invisible, and your docs stay honest.