TSDoc the public surface
Document your code's public surface with TSDoc, so the IDE hover becomes the reference a caller reads at the call site.
A teammate, or future-you, is wiring up a new screen and types createInvoiceDraft(. They hover the call, and the tooltip shows the signature and nothing else. Does it write to the database? Send an email? On bad input, does it throw, or return an error to handle? The signature can’t say, so they open the file, read the body, rebuild the contract in their head, and lose ten minutes. That cost is charged to every caller.
A doc comment would have answered all of it in the hover. But a doc comment on everything fails the other way: a block on every private helper buries the few that matter, and the file becomes half comment. The hard part of this lesson is the judgment of which declarations earn a block; the tag syntax you’ll have in five minutes.
This continues the thread from the previous chapter, Docs that live next to the truth: keep docs next to the code they describe, and link to a source of truth instead of copying it. Now you zoom in, to the documentation that lives inside the source file. The deliverable is never a generated docs website; it’s a good IDE hover.
What TSDoc is and who reads it
Section titled “What TSDoc is and who reads it”TSDoc is the standard for TypeScript doc comments: a /** ... */ block above a declaration, holding a small set of @-prefixed tags. The smallest one is a single summary sentence, no tags yet:
/** Creates a draft invoice for the active organization and returns its id. */export async function createInvoiceDraft(input: CreateInvoiceInput): Promise<Result<{ id: string }>> {What makes the block worth writing is where it surfaces. Every IDE renders it on hover at the call site, so the contract reaches the caller without their opening the file. For a closed-source SaaS app, that hover is the reference documentation. (Tools like API Extractor and TypeDoc render the same syntax into HTML reference sites, but only published libraries need that, as you’ll see at the end.)
One contrast underlies the rest of the lesson: the doc comment states the contract; the body states the implementation. The contract is what a caller needs from the call site: what the function does, what it assumes, what it returns, and how it fails. The how stays in the body. A doc comment is reference documentation in code form.
The public-surface cut: which declarations earn a block
Section titled “The public-surface cut: which declarations earn a block”This is where the judgment lives; once the cut is right, the rest is mechanical.
The inclusion test
Section titled “The inclusion test”A declaration earns a TSDoc block if any one of these is true:
- It crosses a module boundary. It’s exported from
src/lib/<feature>/and used insrc/app/, or it’s a Server Action a component calls. Once a declaration is read one call site away, its contract needs to be stated where that reader hovers. - The type alone doesn’t carry the contract. There’s a precondition, a side effect, an ordering requirement, or an error behavior the signature can’t express.
(orgId: string) => Promise<void>can’t tell you it sends an email. - The next reader is at the call site, not in the file. If the only reader is the one editing the file it lives in, the block has no audience.
When none hold, the default is no: the name and type are already the contract.
The cut is the public surface, so doc volume should track doc value. A block that adds nothing the call site can’t already see isn’t neutral; it’s noise that dilutes the blocks that matter.
The SaaS surfaces that always earn a block
Section titled “The SaaS surfaces that always earn a block”Five surfaces this course has already built almost always land on yes.
The richest is a Server Action, the course’s typed API entry point since the Server Actions chapter. A caller hovering an action needs four things the signature can’t give: a summary of what it does, the precondition it assumes, the side effects it causes, and the failure modes to handle.
/** * Creates a draft invoice for the active organization and returns its id. * * Assumes an authenticated org context — only call it from code a signed-in * member reaches. Writes one `invoices` row and one `invoice_events` audit * row in a single transaction. * * @param input - the draft fields, validated against {@link createInvoiceSchema} * @returns a `Result` with the new invoice's id, or a validation error * @throws when called without an authenticated org context */export const createInvoiceDraft = authedAction( 'member', createInvoiceSchema, async (input, ctx) => { // … },);The first sentence is the whole hover. Verb-first (“Creates…”), declarative, no preamble. A reader scanning the tooltip decides from this line alone whether they’re at the right function.
/** * Creates a draft invoice for the active organization and returns its id. * * Assumes an authenticated org context — only call it from code a signed-in * member reaches. Writes one `invoices` row and one `invoice_events` audit * row in a single transaction. * * @param input - the draft fields, validated against {@link createInvoiceSchema} * @returns a `Result` with the new invoice's id, or a validation error * @throws when called without an authenticated org context */export const createInvoiceDraft = authedAction( 'member', createInvoiceSchema, async (input, ctx) => { // … },);The body carries what the signature can’t: the precondition (an authenticated org context) and the side effects (a row write plus an audit row, in one transaction). A caller can’t infer this from (input) => Promise<...>.
/** * Creates a draft invoice for the active organization and returns its id. * * Assumes an authenticated org context — only call it from code a signed-in * member reaches. Writes one `invoices` row and one `invoice_events` audit * row in a single transaction. * * @param input - the draft fields, validated against {@link createInvoiceSchema} * @returns a `Result` with the new invoice's id, or a validation error * @throws when called without an authenticated org context */export const createInvoiceDraft = authedAction( 'member', createInvoiceSchema, async (input, ctx) => { // … },);The single @param names the argument’s meaning, not its type. Notice there’s no @param orgId: the caller never passes it. orgId arrives on the wrapper’s ctx argument, which authedAction injects from the session. You document input because that’s what the caller controls; the framework-supplied ctx gets no tag.
/** * Creates a draft invoice for the active organization and returns its id. * * Assumes an authenticated org context — only call it from code a signed-in * member reaches. Writes one `invoices` row and one `invoice_events` audit * row in a single transaction. * * @param input - the draft fields, validated against {@link createInvoiceSchema} * @returns a `Result` with the new invoice's id, or a validation error * @throws when called without an authenticated org context */export const createInvoiceDraft = authedAction( 'member', createInvoiceSchema, async (input, ctx) => { // … },);The two failure modes split by how the caller meets them. Bad input comes back in the Result (the course’s Server Action contract), documented on @returns. A violated precondition throws past the framework boundary, documented on @throws. Error semantics are exactly what a signature can’t show.
/** * Creates a draft invoice for the active organization and returns its id. * * Assumes an authenticated org context — only call it from code a signed-in * member reaches. Writes one `invoices` row and one `invoice_events` audit * row in a single transaction. * * @param input - the draft fields, validated against {@link createInvoiceSchema} * @returns a `Result` with the new invoice's id, or a validation error * @throws when called without an authenticated org context */export const createInvoiceDraft = authedAction( 'member', createInvoiceSchema, async (input, ctx) => { // … },);Rather than list every field, the @param points at the Zod schema, which is the field-by-field reference. Restating it here would only create a second copy to keep in sync. This is the link-don’t-duplicate reflex, covered shortly.
That’s the full anatomy. The other four surfaces follow the same shape.
Exported functions in src/lib/<feature>/ are consumed across the app, so their contract belongs where the reader hovers. Internal helpers in the same module don’t qualify: the boundary is the export, not the folder.
/** * Builds the R2 object key for an upload, namespaced to the org so one * tenant can never address another tenant's files. */export const buildObjectKey = (orgId: string, filename: string): string => `orgs/${orgId}/uploads/${filename}`;Drizzle schema tables earn a block on the exported pgTable identifier, so hovering the table name at a query site surfaces what the table is for. This pairs with the one-paragraph table header from the source-as-doc lesson.
/** One row per issued invoice; tenant-scoped on `organizationId`. */export const invoices = pgTable('invoices', { id: uuid().primaryKey().$defaultFn(() => uuidv7()), // …});Zod schemas exposed across modules get a one-line summary on the exported schema describing what the shape represents, such as “the shape the profile-update action accepts.” Field-level intent lives in the schema’s own .meta({ description }), so you don’t restate fields here either.
Webhook handlers get a block on the route handler stating the contract: which event types it accepts, the idempotency key it dedupes on, the side effects, and the response it returns. The handler is the webhook contract doc.
/** * Stripe webhook receiver. Verifies the signature, then dedupes on the * event id via `processed_events` before projecting `checkout.session.completed` * and `customer.subscription.*` into the org's plan entitlements. */export async function POST(request: Request) { // …}The negative space: what does NOT earn a block
Section titled “The negative space: what does NOT earn a block”The cut only sticks once the no cases are as clear as the yes cases.
- Internal helpers, with one file and one caller. The name and type are the contract, and there’s no second reader to serve.
- React components, which the props type and component name already document. The one exception is a shared design-system primitive whose prop semantics are non-obvious.
- Trivial passthroughs and getters, like
const isAdmin = (user) => user.role === 'admin'. The body is the doc. - Test helpers, read by the test author and its reviewer, both right there in the file. Clear names are enough.
- Type aliases that mirror a schema, like
type UserInput = z.infer<typeof userInputSchema>. The schema is the doc; the type is just its derived view. A block here would be a copy of a copy.
The same idea in code: these two tabs are the same file, over-documented and then right-sized.
/** Converts a cents integer to a dollars number. */const centsToDollars = (cents: number): number => cents / 100;
/** Returns true when the invoice is a draft. */const isDraft = (invoice: Invoice): boolean => invoice.status === 'draft';
/** Creates a draft invoice for the active org and returns its id. */export const createInvoiceDraft = authedAction('member', createInvoiceSchema, async (input, ctx) => { // …});The one block that matters, the action’s, is buried under blocks that restate names and types. centsToDollars and isDraft are one-file privates whose name and type already say everything. The signal is drowned.
const centsToDollars = (cents: number): number => cents / 100;
const isDraft = (invoice: Invoice): boolean => invoice.status === 'draft';
/** Creates a draft invoice for the active org and returns its id. */export const createInvoiceDraft = authedAction('member', createInvoiceSchema, async (input, ctx) => { // …});One block, on the one surface a caller hovers. The two privates keep their contract in their names and types. Only the exported action, read a call site away, earns the block.
Now make the call yourself.
Sort each declaration into whether it earns a TSDoc block. The cut is the public surface — what a reader hovers from a call site. Drag each item into the bucket it belongs to, then press Check.
pgTableconst isAdmin = (u) => u.role === 'admin'/lib function a component importstype UserInput = z.infer<typeof userInputSchema>Two chips trip people up. The self-describing React component: its props type already documents it, so a block would echo the type. And the z.infer alias: it’s a derived view of the schema, and the schema is the doc. When the type carries the whole contract, a block is just one more copy to keep in sync.
Write the first sentence verb-first
Section titled “Write the first sentence verb-first”How a hover renders dictates how you write it. The IDE shows the signature, then your first sentence prominently, and everything else below the fold, where most readers never scroll. So write that first sentence so a reader can decide in two seconds whether they’re at the right function: verb-first, declarative, no preamble.
The contrast is the whole point:
- Good:
Creates an invoice draft and returns its id. - Bad:
This function is used to create an invoice draft.
The bad one opens with four words every doc comment could start with, “This function is used to,” so the reader’s eye scans past them before reaching the verb. The good version leads with it. Flip the tabs to see the gap in the rendered hover:
Everything below that first line follows one rule: include only what the call site can’t already see, such as preconditions, side effects, and failure modes. That rule brings us to the most common way it gets broken.
TSDoc states the contract, not the type
Section titled “TSDoc states the contract, not the type”If you’ve written doc comments in Java or in plain JavaScript with JSDoc, you carry one habit that’s wrong in TypeScript: repeating the type in the comment.
TypeScript states parameter and return types in the signature, where the compiler keeps them honest. The type is already there, in the one place that can’t drift, so the doc comment must not repeat it. Compare the same function written the JSDoc way and the TSDoc way:
/** * Creates a customer. * * @param {string} email - the email * @returns the customer object */export async function createCustomer(email: string): Promise<Customer> {The {string} and @returns the customer object both restate what the signature already says. They drift the day the types change, and neither tells the caller anything new.
/** * Creates a Stripe customer for the org's billing email. * * @param email - the billing email the customer is created against */export async function createCustomer(email: string): Promise<Customer> {No type in the tag; the signature owns the type. The @param adds what the type can’t: why this address matters. @returns is dropped because Promise<Customer> already says it.
State it as a rule: TSDoc tags never carry types. No @param {string}. JSDoc needed types because plain JavaScript had none, so the comment was the only place a type could live. TypeScript moved the type to the signature, leaving the tag only the part the signature can’t express: the parameter’s purpose.
The same logic governs @returns. Document the return only when its semantics go past its type: a sentinel null meaning “not found,” a partial result, or an ordering guarantee. @returns the user object on a function that already returns User is noise. The tooltip below shows a return that earns its tag:
/** * Looks up an invoice by id within the active org. * * @returns the invoice, or `null` when it doesn't exist or belongs to another org */export async function getInvoice(id: string): Promise<Invoice | null> {Link, don’t duplicate, in a doc block
Section titled “Link, don’t duplicate, in a doc block”The previous chapter’s reflex was to ask, before paraphrasing a source of truth, whether you could link to it instead. The same reflex applies in TSDoc: when a contract is already stated somewhere structural, the block points there instead of restating it. A function that validates a Zod-checked input doesn’t list the fields; it links to the schema, whose .meta({ description }) fields are the field-by-field reference. A function that reads config names STRIPE_WEBHOOK_SECRET and lets env.ts be the env doc. The two tabs show the same action documented both ways:
/** * Creates a draft invoice for the active organization. * * @param input.customerId - the customer the invoice is for * @param input.amountCents - the total in cents * @param input.dueDate - the ISO date payment is due * @param input.notes - optional free-text notes shown on the invoice */export const createInvoiceDraft = authedAction('member', createInvoiceSchema, async (input, ctx) => { // …});This list drifts the day someone adds, renames, or removes a field. The shape now has two definitions, the schema and this prose copy, and the reader can’t tell which one is wrong.
/** * Creates a draft invoice for the active organization. * * @param input - the draft fields, validated against {@link createInvoiceSchema} */export const createInvoiceDraft = authedAction('member', createInvoiceSchema, async (input, ctx) => { // …});One source of truth. The schema can’t drift from itself, and the hover still gives the caller a one-click path to the field list, where each field’s intent lives in .meta({ description }).
A duplicated field list isn’t merely likely to drift; it’s certain to, the next time the schema changes and nobody updates the prose copy. A drifted doc is worse than no doc: it lies to the next reader, who must discover it’s wrong before they can ignore it. The link is the only form that can’t drift, because it stores no second copy.
@example, @deprecated, and the tags you’ll rarely need
Section titled “@example, @deprecated, and the tags you’ll rarely need”You’ve met four of the five tags worth your time: @param, @returns, @throws, and the inline {@link}. Two more earn their place in specific situations, and two you’ll almost never reach for.
@example: when one call shape earns it
Section titled “@example: when one call shape earns it”Add an @example only when the call shape is non-obvious: an action with several optional parameters, or a usage a reader would otherwise have to guess at. When the call is obvious from the signature, the example would only restate it, so skip it.
When you do write one, it has to run as-is. An example with a // ... in the middle is a sketch, not an example. Here, a function’s optional second argument earns one:
/** * Archives an invoice. Pass `{ notify: true }` to also email the customer. * * @example * await archiveInvoice(invoice.id, { notify: true }); */export async function archiveInvoice( invoiceId: string, options?: { notify?: boolean },): Promise<Result<void>> {@deprecated: never deprecate without a path
Section titled “@deprecated: never deprecate without a path”When you retire a declaration, keep its summary and add a @deprecated line that names the replacement: @deprecated use createInvoiceDraft instead — removed in the next major. That path is the whole point.
The caller sees the strikethrough while typing and reads the replacement in the hover, redirected before they’ve written a line against the dead function. A bare @deprecated tells them to stop without saying where to go, sending them into the file you were keeping them out of.
The two you’ll rarely type: @remarks and @see
Section titled “The two you’ll rarely type: @remarks and @see”@remarks is for a block that truly needs a second section beyond its summary. Most don’t. If you reach for it to record why the code is shaped this way, that’s architectural rationale, and rationale belongs in an ADR, not in a hover.
@see adds a “see also” pointer, but an inline {@link} in the summary usually does the job in fewer characters, right where the reader is already looking. Reach for @see only when a standalone pointer reads better.
Here’s the whole working set in one place:
| Tag | Earns its place when… | Skip it when… |
|---|---|---|
@param | the parameter’s purpose isn’t obvious from its name and type | the name already says it (and never to state the type) |
@returns | the return carries semantics beyond its type: a sentinel, a partial result, an ordering | the type already says it (@returns the user object) |
@throws | the caller must handle a specific failure mode | the function doesn’t throw anything the caller acts on |
@example | the call shape is non-obvious; one runnable call clarifies it | the signature already makes the call obvious |
@deprecated | a declaration is retiring, always with the replacement path | never; it’s the path that makes it useful |
@remarks (rare) | the summary genuinely needs a second section | the one paragraph covers it, which is almost always |
@see (rare) | a standalone pointer reads better than an inline {@link} | an inline link in the summary already does the job |
Re-read your own hover before the next editor does
Section titled “Re-read your own hover before the next editor does”Every block reduces to one check. After you write it, re-read your own hover and ask: can a caller decide in five seconds whether to use this, and how? If it runs three paragraphs of internal rationale, the block has drifted, either into reasoning that belongs in an ADR or into implementation narration that belongs nowhere. Cut it to what the caller needs.
That cut earns its keep twice. The hover is also the brief anyone editing the function reads to learn its contract before touching the body, whether a human reviewer two months from now or an agent changing code across the codebase. With the contract stated in the block, the editor works from it; without it, they infer it from the implementation, and inference is where wrong changes come from. The hover is the contract you hand the next editor, whoever or whatever they are.
TypeDoc and API Extractor turn these same comments into a published HTML reference site. For a library, that site is load-bearing: the docs are the product surface the world consumes. For a closed-source web app, the IDE hover is the reference surface, so the course doesn’t reach for TypeDoc by default. Publish a package and the calculation flips; until then, your deliverable stays the hover.
Now put the cut to work. The following pull request adds and changes TSDoc across two files. Review it as you would a teammate’s: click any line with a defect and name what’s wrong.
Review this PR's TSDoc as if a teammate opened it. Click any line with a defect and name it. There are four. Click any line to leave a review comment, then press Submit review.
import { db } from '@/lib/db';import { createInvoiceSchema } from './schema';
/** Converts a cents integer to a dollars number for display. */const centsToDollars = (cents: number): number => cents / 100;
/** * Creates a draft invoice for the active organization. * * @param {string} customerId - the customer the invoice is for */export const createInvoiceDraft = authedAction( 'member', createInvoiceSchema, async (input, ctx) => { // … },);import { z } from 'zod';
/** * Input for creating an invoice. * * Fields: * - customerId: the customer id * - amountCents: the amount in cents * - dueDate: the due date * * @deprecated */export const createInvoiceSchema = z.object({ customerId: z.uuid(), amountCents: z.int().positive(), dueDate: z.iso.date(),});centsToDollars is used once, in this same file — there’s no second reader to serve. Its name and type already carry the whole contract, so the block adds nothing a caller hovering elsewhere would need. Worse, every block on a private dilutes the one block that matters: the action’s. Delete it.
@param {string} is JSDoc muscle memory. The {string} duplicates the type the signature already states, and it drifts the day the type changes. Keep the tag’s purpose text — drop the brace-type. The right line is @param customerId - the customer the invoice is for.
The schema right below is already the field-by-field reference. This bullet list is a second copy that goes silently wrong the moment a field is added, renamed, or removed. Replace the whole Fields: list with a one-line summary of what the shape represents — Input for creating an invoice. is enough; the schema (and its .meta({ description }) fields) is the field doc.
A bare @deprecated tells the caller to stop without telling them where to go — it just sends them into the file to figure it out. Name the replacement and the timeline so the IDE strike-through actually redirects them: @deprecated use createInvoiceSchemaV2 instead — removed in the next major.
Every defect here is one of the lesson’s two reflexes. Plants A and C are the public-surface cut: a block with no second reader (the private helper) and a block that duplicates a source of truth (the field list) are both noise — the cut says document the contract, on the surface a caller hovers, and link to the truth instead of copying it. Plants B and D are the small disciplines that keep a block honest: TSDoc never carries the type, and you never deprecate without a path. If you flagged all four, you can read a diff the way the next reader will.
Where to go deeper
Section titled “Where to go deeper”The official TSDoc site is the one resource worth bookmarking: the spec, with the full tag reference and a live playground for trying syntax.
Microsoft's spec for TypeScript doc comments, with the full tag reference.
Type a doc comment and watch it parse against the standard in real time.
Exactly which doc-comment tags the TypeScript compiler understands, including `@deprecated`, `@see`, and `@link`.
The generator that turns these same comments into a published HTML reference site — for when you ship a library.