TSDoc the public surface
Document your code's public surface with TSDoc, the doc-comment standard that turns the IDE hover into the reference a caller reads at the call site.
It’s two months after you shipped the invoicing feature. A teammate, or future-you with less memory of the details, is wiring up a new screen and types createInvoiceDraft(. They hover the call to remind themselves what to pass and what comes back. The tooltip shows the signature and nothing else. Does it write to the database? Does it send an email? When the input is malformed, does it throw, or return an error they need to handle? The signature can’t say, so they open the file, read the body, reconstruct the contract in their head, and lose ten minutes. That cost gets charged to every caller, every time.
A doc comment on createInvoiceDraft would have answered all of it in the hover, before they ever left the call site. The obvious response is to write a doc comment on everything, but that fails the other way: a block on every private helper buries the few that matter under noise, and the file becomes half comment. This lesson teaches the cut between those two failures, deciding which declarations earn a doc block and which don’t. The tag syntax is small, and you’ll have it in five minutes. The judgment is the part that takes the rest of the lesson.
This continues the same thread from the previous chapter, Docs that live next to the truth: docs live next to the code they describe, and you link to a source of truth instead of copying it. There you saw the four artifacts a repo maintains from the outside: README, AGENTS.md, ADRs, and source-as-reference. Now you’re zooming all the way in, to the documentation that lives inside the source file, on the function or type or schema itself.
Here is what the lesson covers. You’ll get a three-part test for deciding yes or no on any declaration, a working set of five tags along with the judgment to keep it small, and a writing posture built around one fact: the first sentence is what the reader actually sees. One reflex carries over from last chapter, link rather than duplicate. The deliverable is never a generated docs website; it’s a good IDE hover.
What TSDoc is, in one pass
Section titled “What TSDoc is, in one pass”TSDoc is the Microsoft-stewarded standard for doc comments in TypeScript: a /** ... */ block placed directly above a declaration, with a small set of @-prefixed tags inside it. That’s the whole format. Here’s the smallest one, a single summary sentence with 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 }>> {The reason to bother is who reads it and where. Every IDE, whether VS Code, WebStorm, or Cursor, renders that block on hover at the call site. A developer using the function never has to open the file where it’s defined; the contract comes to them. For a closed-source SaaS app, that hover is the reference documentation, and it’s the only surface that matters. There is a second consumer: tools like TypeDoc and API Extractor generate full HTML reference sites from the same syntax. That’s for published libraries, where the docs are the product, so it isn’t where your effort goes on a SaaS app. We’ll come back to that threshold at the end.
One contrast underlies the rest of the lesson, so it’s worth fixing now: the doc comment states the contract; the body states the implementation. The contract is what a caller needs to know from the call site, namely what the function does, what it assumes, what it gives back, and how it fails. The implementation is how it pulls that off, which a caller doesn’t need to know. In the vocabulary from last chapter, 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”Most of the judgment lives here. Once you get the cut 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 over insrc/app/, or it’s a Server Action a component calls, or a util imported elsewhere. The moment a declaration is read one call site away from where it’s defined, 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 that the signature simply cannot 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. A teammate or future-you will hover the name rather than open the body. If the only person who ever reads this declaration is the one editing the file it lives in, the block has no audience.
When none of those hold, the default is no. A private helper used once in the same file already has its name and type as the contract. A one-liner whose body is shorter than the doc would be is its own doc. A component whose props type already tells the story is documented by that type. None of these earn a block.
The posture underneath the test is that the cut is the public surface, so doc volume should track doc value. A block that doesn’t add what the call site can’t see isn’t neutral. It’s noise, and noise has a cost, because it dilutes the blocks that do matter.
The SaaS surfaces that always earn a block
Section titled “The SaaS surfaces that always earn a block”Let’s make the test concrete on the exact surfaces this course has already built. These five are where the public-surface cut almost always lands on yes.
The richest case 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 them: a plain summary of what it does, the precondition it assumes, the side effects it causes, and the failure modes they have to handle. We’ll walk one part by part.
/** * 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. It’s verb-first (“Creates…”), declarative, and free of preamble. A reader scanning the tooltip decides from this line alone whether they’re looking 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 of the block carries what the signature can’t: the precondition (“assumes an authenticated org context”) and the side effects (a row write plus an audit row, in one transaction). This is the part a caller can’t infer 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) => { // … },);There’s a single @param, and it earns its place by naming the meaning of the argument rather than its type. Notice what’s missing: there’s no @param orgId. The caller never passes orgId; it arrives on the wrapper’s second ctx argument (async (input, ctx) => ...), which authedAction injects from the session. You document the first argument, input, because that’s what the caller controls. The framework-supplied ctx doesn’t get a 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 are split by how the caller meets them. A bad input comes back in the Result (the course’s Server Action contract), so it’s documented on @returns. A violated precondition, such as calling it from public code, throws past the framework boundary, so it’s documented on @throws. Error semantics are exactly the kind of contract 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) => { // … },);Instead of listing every field the action accepts, the @param points at the Zod schema. The schema is the field-by-field reference, and restating it here would only create a second copy to keep in sync. This is the link-don’t-duplicate reflex, which gets its own section shortly.
That walkthrough is the full anatomy. The other four surfaces follow the same pattern, so a quick look at each is enough once you’ve seen the 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, because 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 that hovering the table name at a query site surfaces what the table is for. This pairs with the one-paragraph table header you saw in 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.” The 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. As you saw last chapter, 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. This is where over-documenting gets corrected, so read the reasons, not just the list.
- 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 the component name already document. A block on every component is pure noise. The one exception is a shared design-system primitive whose prop semantics are genuinely non-obvious.
- Trivial passthroughs and getters, like
const isAdmin = (user) => user.role === 'admin'. The body is the doc, and a comment above it would say less than the line itself. - Test helpers, read by the test author and the test’s reviewer, both of whom are 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, and the type is just the derived view of it. A block here would be a copy of a copy.
Here’s the same idea in code. The following two tabs are the same file, once over-documented and once 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, so their blocks add nothing a caller would hover. 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, so centsToDollars and isDraft need no prose. Only the exported action, read a call site away, earns the block.
Now make the call yourself. Sort each declaration by whether it earns a block.
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 first is the self-describing React component: its props type already documents it, so a block would just echo the type. The second is the z.infer alias: it’s a derived view of the schema, and the schema is the doc. When the type already carries the whole contract, a block is a copy, and a copy is one more thing to keep in sync.
The first sentence is the whole doc
Section titled “The first sentence is the whole doc”Look again at how a hover renders, because it dictates how you write. The IDE shows the signature, then your first sentence prominently, and everything else sits below the fold. Most readers never scroll. So write the 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 of throat-clearing, “This function is used to,” that every doc comment could start with, so they carry no information. By the time the reader’s eye reaches the verb, they’ve scanned past noise. The good version leads with the verb and is done.
Here’s what the reader actually sees in each case. The following figure mocks up the hover card the IDE renders; flip between the two tabs to see the difference at a glance.
Everything below that first line follows one rule: the block adds only what the call site can’t already see, such as preconditions, side effects, failure modes, and the one example worth showing. Anything the signature already states, the block leaves out. 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 another language, such as Java or plain JavaScript with JSDoc, you carry a habit that’s wrong here. It’s worth naming directly, because nearly everyone does it on their first TypeScript block.
The course’s TypeScript style is inference-led: you annotate the parameter and return types right in the signature. That means the type is already stated once, in the one place guaranteed to stay correct, since the compiler checks it. So the doc comment must not repeat it. Compare the two tabs below, 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} duplicates what the signature already says, and @returns the customer object restates the return type. Both 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.
This is the line that separates TSDoc from JSDoc, so it’s worth stating 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 the type could live. TypeScript moved the type to the signature, which shrank the tag’s job to the part the signature can’t express: the parameter’s purpose.
The same logic governs @returns. Only document the return when its semantics go past its type, such as a sentinel null that means “not found,” a partial result, or an ordering guarantee. @returns the user object on a function that already returns User is pure noise, so drop it. Here’s the rule in a tooltip, on a block that does it right:
/** * 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: the reflex applied inline
Section titled “Link, don’t duplicate: the reflex applied inline”Last chapter’s reflex was to ask, before you paraphrase a source of truth, whether you could link to it instead. That reflex moves straight into TSDoc, and it’s the difference between a doc that stays correct for free and one that’s guaranteed to go wrong.
When a function’s contract is already stated somewhere structural, the block points at that place instead of restating it. A function that validates a Zod-checked input doesn’t list the input’s fields; it links to the schema, and the schema (with its .meta({ description }) fields) is the field-by-field reference. A function that reads config doesn’t describe the variable; it names STRIPE_WEBHOOK_SECRET and lets env.ts be the env doc. The two tabs below 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. Now there are two definitions of the shape, the schema and this prose copy, and one of them is silently wrong. The reader can’t tell which.
/** * 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 the schema’s own .meta({ description }).
The reason is economics, which is what makes this a reflex rather than a style preference. A duplicated field list isn’t merely likely to drift; it’s certain to, the next time the schema changes and the person changing it doesn’t think to update the prose copy. A drifted doc is worse than no doc, because it lies to the next reader, who now has to discover it’s wrong before they can ignore it. You’ll see that asymmetry made structural in a couple of lessons, when we get to keeping docs in sync at review time. The link is the only form that can’t drift, because it doesn’t store a second copy of anything.
@example, @deprecated, and the tags that rarely earn their weight
Section titled “@example, @deprecated, and the tags that rarely earn their weight”You’ve now met four of the five tags worth your time: @param, @returns, @throws, and the inline {@link}. Two more pull their weight in specific situations, and a couple exist that you’ll almost never reach for. Let’s close out the toolkit and, just as importantly, keep it small.
@example: when one call shape earns its weight
Section titled “@example: when one call shape earns its weight”Add an @example only when the call shape is non-obvious, such as an action with several optional parameters or an unusual usage a reader would otherwise have to guess at. A function whose call is obvious from its signature doesn’t get one, since the example would just restate the signature.
When you do write one, it has to be runnable as-is. An example with a hand-wavy // ... in the middle isn’t an example; it’s a sketch, and the reader can’t trust it. Here’s a function whose optional second argument earns its one example:
/** * 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 intact and add a @deprecated line that names the replacement: @deprecated use createInvoiceDraft instead — removed in the next major. The replacement path is the whole point, and the reason is what the IDE does with it.
The caller sees the strikethrough while typing and reads the replacement in the hover. They’re redirected at the call site, before they’ve written a line against the dead function. A bare @deprecated with no path tells them to stop without telling them where to go, which just sends them into the file you were trying to keep them out of.
The two you’ll rarely type: @remarks and @see
Section titled “The two you’ll rarely type: @remarks and @see”@remarks exists for when a block genuinely needs a second section beyond its one-paragraph summary. Most don’t. If you find yourself reaching for it to write down why the code is shaped this way, that’s architectural rationale, and rationale belongs in an ADR, not in a hover the caller reads to decide how to call the function.
@see adds a “see also” pointer, but an inline {@link} in the summary usually does the job in fewer characters and in the spot the reader is already looking. Reach for @see only when a standalone pointer reads better than an inline one.
Here’s the whole working set in one place, a reference to come back to rather than something to memorize:
| 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 |
The hover is the usability test (and the audit trail)
Section titled “The hover is the usability test (and the audit trail)”Everything in this lesson collapses into one reusable check. After you write a block, re-read your own hover and ask: can a caller decide in five seconds whether to use this, and how? If the hover dumps three paragraphs of internal rationale, the block has drifted, either into explanation that belongs in an ADR or into implementation narration, which belongs nowhere. Cut to what the caller needs. That’s the test, and it’s the whole posture in one sentence.
There’s a second reason the cut earns its keep in 2026, beyond saving the next caller a trip into the file. The same block is the brief that anyone editing the function reads to learn its contract before they touch the body, whether that’s a human reviewer two months from now or an automated agent making a change across the codebase. If the contract is stated in the block, the editor works from it. If it isn’t, they read the implementation and infer the contract, and inference is where wrong changes come from. The hover is the contract you hand the next editor, whoever or whatever they are. That’s why a stated public surface is a property of the codebase, not a personal nicety.
One last threshold, stated once and then dropped: TypeDoc and API Extractor turn these same comments into a published HTML reference site. For a library, such as an SDK or a component package, that site is load-bearing, because the docs are the product surface the world consumes. For a closed-source SaaS app, the IDE hover is the reference surface, and the course doesn’t reach for TypeDoc by default. If you ever publish a package, the calculation flips and you set it up then. Until then, your deliverable stays the hover.
Now put the whole cut to work. The following is a small pull request that adds and changes TSDoc across two files. Review it the way you’d review a teammate’s PR: click any line that has a defect and leave a comment naming 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. It’s 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.