tsdocs
This skill is the practical companion to the project's TSDoc rules. It shows what good TSDoc looks like in *this* codebase, points at real files to mine for patterns, and calls out common gaps so you can fix them when you touch a file.
Install
mkdir -p .claude/skills/tsdocs && curl -L -o skill.zip "https://agentskills.codes/api/skills/download/17244" && unzip -o skill.zip -d .claude/skills/tsdocs && rm skill.zipInstalls to .claude/skills/tsdocs
Activation
This is the description your AI agent reads to decide when to run this skill — the better it matches your request, the more reliably it fires.
This skill is the practical companion to the project's TSDoc rules. It shows what good TSDoc looks like in *this* codebase, points at real files to mine for patterns, and calls out common gaps so you can fix them when you touch a file.About this skill
TSDocs Skill
Overview
This skill is the practical companion to the project's TSDoc rules. It shows what good TSDoc looks like in this codebase, points at real files to mine for patterns, and calls out common gaps so you can fix them when you touch a file.
TSDoc vs JSDoc. TSDoc is a documentation comment standard maintained by Microsoft, designed specifically for TypeScript. JSDoc predates it and was built for JavaScript — its tags often double as type annotations (@param {string}), which is redundant when the language already has types. TSDoc drops type-in-comment annotations and standardizes the tag grammar so tools (API Extractor, VS Code, ESLint plugins) parse the same comments the same way. This project uses TSDoc so editor hovers, future generated docs, and lint rules all agree.
Why TSDoc matters here. It powers three concrete things:
- Editor hovers — every
@param,@returns,@throwsyou write shows up when a teammate hovers over the call site in VS Code. - Generated docs — if/when we publish API reference pages, TSDoc is what gets extracted.
- Reviewer attention — a missing
@throwsis the single most common reason a caller forgets to handle an error.
Authoritative reference
The canonical TSDoc rules for this repo live in .github/instructions/documentation.instructions.md — explicitly marked SINGLE SOURCE OF TRUTH. This skill defers to that file for any conflict. If something here disagrees with the instructions file, the instructions file wins and this skill should be updated.
In particular, the instructions file owns:
- The proportionality rule (TSDoc lines ≤ function body lines).
- The right-sizing table (≤10 LOC / 11–30 LOC / >30 LOC).
- The canonical TSDoc Tags Reference (when to include
@param,@returns,@throws,@example,@remarks,@see,@internal,@public,@deprecated). - Inline comment prefix conventions (
// PERF:,// NOTE:,// CRITICAL:,// TODO:,// FIXME:). - The anti-pattern catalogue.
Read it first. This file is examples.
Anatomy of a good TSDoc comment in this repo
A clean, working example: requireUserContext() in src/lib/auth/context.ts:
/**
* Like {@link getUserContext} but throws {@link UnauthorizedError} when the
* request is unauthenticated. Use this inside API route handlers.
*/
export async function requireUserContext(): Promise<UserContext> {
const ctx = await getUserContext();
if (!ctx) throw new UnauthorizedError();
return ctx;
}
What this gets right:
- Proportional. Two lines of prose for a three-line function — the instructions file's hard rule (TSDoc lines ≤ body lines) is satisfied.
- Intent, not mechanics. It says what calling this means for you ("throws when unauthenticated, use in API routes"), not "calls
getUserContextand checks if null". {@link}cross-refs to related symbols so hover docs are navigable.- No
@param/@returns— there are no params, and the return typePromise<UserContext>is self-documenting. The instructions file explicitly allows skipping these when the signature speaks for itself. - No
@throwsplaceholder. It mentionsUnauthorizedErrorin prose via{@link}— for a two-line summary that's enough. (For richer surfaces, seewithUserGuardsbelow.)
A longer, fully-tagged example: withUserGuards() in src/lib/security/guard.ts:
/**
* Apply auth + rate-limit + concurrent-cap + audit logging around `work`.
*
* @throws {@link RateLimitedError} when the user is over the rate limit.
* @throws {@link TooManyConcurrentSessionsError} when the user is over the
* concurrent-session cap.
* @throws {@link UnauthorizedError} when the request is unauthenticated.
*/
export async function withUserGuards<T>(
opts: GuardOptions,
work: (ctx: UserContext) => Promise<T>,
): Promise<T> {
This pattern — one-line summary + a @throws per failure mode + an @example block at the file or symbol level — is the right shape for any exported function that can throw. Note the file-level @example directly above the export shows the canonical call site for reviewers, without bloating each function's own comment.
Required tags by export kind
The canonical rules are in documentation.instructions.md § TSDoc Tags Reference. Quoting the operative line:
|
@param| Always for all parameters | Parameter name is self-documenting AND ≤10 LOC function | |@returns| Non-void functions | Return type is obvious (e.g.,getName(): string) | |@throws| Function can throw | Function never throws |
Practical translation by export kind:
| Export kind | Summary | @param | @returns | @throws | @example | @deprecated |
|---|---|---|---|---|---|---|
| Exported function | Required | Yes, unless self-documenting & ≤10 LOC | Yes, unless return type is obvious | One per throwable error | For non-obvious APIs / public surface | Only if phasing out (include migration path) |
| Exported class | Required (one summary on the class) | On the constructor and each public method | On each non-void method | Per throwable method | On the class for canonical usage | If the class is being removed |
| Exported interface | Required | TSDoc each field (one-liner is fine) | n/a | n/a | If the shape is non-obvious | Per-field if individual fields are being removed |
| Exported type alias | Single-line TSDoc is usually enough | n/a | n/a | n/a | Only for non-obvious unions/discriminants | Rare |
| Exported constant | Single-line TSDoc explaining intent / units | n/a | n/a | n/a | If usage is non-obvious | Rare |
Defer to the instructions file's right-sizing table for how much prose. The table above only covers which tags — length is a separate axis.
Interface field documentation
The codebase already does this well. From src/lib/security/rate-limit.ts:
export interface RateLimitResult {
/** Whether the request is allowed under the current window. */
allowed: boolean;
/** Milliseconds the caller should wait before retrying. Only present when blocked. */
retryAfterMs?: number;
}
One-line TSDoc per field. The optional retryAfterMs mentions when it's present — that's the kind of intent-over-mechanics detail that's worth the line.
What NOT to document
Don't write TSDoc that:
- Restates the type.
@param userId - The user id, a string.adds nothing the signature didn't already say. Write what role the value plays, not its type. - Narrates the implementation.
// Loop through buckets and filter expired entriesis what the code itself shows. The instructions file's anti-pattern table calls this out explicitly. - Lists obvious behaviour.
getUserById(id: string): User | nulldoes not need "Returns the user with the given id, or null if not found." That's the signature in prose. - Is a placeholder.
TODO: write docsis worse than nothing — it signals to readers "stop reading, this is unfinished." Either write it or omit the comment. - Documents private helpers. Internal helpers (
cleanupExpiredinrate-limit.ts) don't need TSDoc — a brief inline// NOTE:is enough when intent isn't obvious. Mark testing-only exports with@internal(see__resetRateLimitState).
Examples of well-documented exports in this codebase
Three real exports worth mining when you write or review TSDoc. Each shows a different right-sized shape, from one-liner field docs through to fully-tagged thrower contracts. (These were rough spots earlier in the project's life; H5 brought them up to standard. They are now the benchmark, not the cautionary tale.)
1. src/lib/auth/token-store.ts — TokenStore interface methods
Every method on the TokenStore contract documents its edge-case
behaviour, because the contract is the Liskov boundary between the
in-memory and Cosmos implementations. Notice how each method explains
the observable corners — what "not found" looks like, whether the
operation is idempotent — rather than restating the signature:
export interface TokenStore {
/**
* Look up the stored token for `userId`.
*
* @param userId - Stable GitHub numeric ID as a string.
* @returns The {@link StoredToken}, or `null` when no record exists for
* the user **or** when the stored record has already expired
* (`expiresAt * 1000 <= now`). Callers must treat "not found" and
* "expired" identically — both mean "refresh / re-auth required".
* Never throws for missing users.
*/
getToken(userId: string): Promise<StoredToken | null>;
/**
* Persist `token` for `userId`.
*
* @param userId - Stable GitHub numeric ID as a string.
* @param token - The token payload to store. Overwrites (upserts) any
* previous record for the same `userId`; there is no separate update
* path.
*/
setToken(userId: string, token: StoredToken): Promise<void>;
/**
* Remove the token record for `userId`.
*
* @param userId - Stable GitHub numeric ID as a string.
* @remarks Idempotent: deleting a `userId` with no stored token is a
* successful no-op, not an error.
*/
deleteToken(userId: string): Promise<void>;
}
What this gets right: every "what does the caller do with the return value?" question is answered at the contract level, not deferred to "see the implementation".
2. src/lib/security/rate-limit.ts — checkRateLimit
This function is a good template for "function with both a return value
and a side effect". The @returns tag describes both arms of the
discriminated result, and the prose calls out the side effect explicitly
so callers know that asking is also recording:
/**
* Check whether a user may make anot
---
*Content truncated.*