Skip to content
Chapter 102Lesson 1

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.

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.

A declaration earns a TSDoc block if any one of these is true:

  1. It crosses a module boundary. It’s exported from src/lib/<feature>/ and used in src/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.
  2. 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.
  3. 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.

1 / 1

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.

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.

Earns a block A reader hovers it from somewhere else
No block The name and type are the contract
An exported Server Action
A private helper used once in the same file
An exported pgTable
const isAdmin = (u) => u.role === 'admin'
An exported /lib function a component imports
A React component whose props interface is self-describing
type UserInput = z.infer<typeof userInputSchema>
A webhook route handler
A test helper

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.

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:

42const id = await createInvoiceDraft(orgId, input); cursor here
function createInvoiceDraft(input: CreateInvoiceInput): Promise<Result<{ id: string }>>
first sentence — what you see Creates a draft invoice and returns its id.
below the fold
@param input — the draft fields
@throws ValidationError
Verb-first: the reader knows what it does before they finish the line.

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.

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.

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> {

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.

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.

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.

42const id = await createInvoice(orgId, input); cursor here
function createInvoice(input: CreateInvoiceInput): Promise<Result<{ id: string }>>
⚠ @deprecated Use createInvoiceDraft instead. Removed in the next major.
below the fold
Creates a draft invoice for the active organization and returns its id.
The strikethrough and the replacement reach the caller at the call site, so they never open the file to learn the function is going away.

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:

TagEarns its place when…Skip it when…
@paramthe parameter’s purpose isn’t obvious from its name and typethe name already says it (and never to state the type)
@returnsthe return carries semantics beyond its type: a sentinel, a partial result, an orderingthe type already says it (@returns the user object)
@throwsthe caller must handle a specific failure modethe function doesn’t throw anything the caller acts on
@examplethe call shape is non-obvious; one runnable call clarifies itthe signature already makes the call obvious
@deprecateda declaration is retiring, always with the replacement pathnever; it’s the path that makes it useful
@remarks (rare)the summary genuinely needs a second sectionthe 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.

src/lib/invoices/actions.ts
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) => {
// …
},
);

The official TSDoc site is the one resource worth bookmarking: the spec, with the full tag reference and a live playground for trying syntax.