Types, Documentation, and Package Design
JSDoc as editor-visible contracts, TypeScript as an optional neighboring path, and designing a package's public surface — exports, versioning, and documentation.
By the end of this lesson, you can
- Write a JSDoc comment that gives editor tooling accurate parameter and return types
- Explain what TypeScript adds over JSDoc, and what gradual adoption looks like
- Design a package's public exports and version its changes according to semantic versioning
- Explain why a type is not a substitute for runtime validation at a real boundary
Why it matters
A function’s real contract — what it expects, what it returns, what can go wrong — is invisible to a reader (and an editor) unless it’s written down somewhere machine-readable. JSDoc and TypeScript both make that contract visible; designing a package’s public surface deliberately, with real versioning discipline, is what makes it safe for other code to depend on.
JSDoc: types without a build step
A JSDoc comment gives editor tooling (autocomplete, inline parameter hints, type-mismatch warnings) accurate information, using plain JavaScript with no compilation step required:
/**
* Calculate the total price for a cart, including tax.
* @param {Array<{ price: number, quantity: number }>} items
* @param {number} taxRate - A decimal, e.g. 0.08 for 8%.
* @returns {number} The total price, tax included.
*/
function calculateTotal(items, taxRate) {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return subtotal * (1 + taxRate);
}An editor configured to check JSDoc (most modern ones are, by default, for .js files) will flag
calculateTotal('not an array', 0.08) as a type mismatch — real, useful feedback with zero build
step.
TypeScript as a neighboring path
TypeScript adds a dedicated type system and compiler on top of JavaScript — the same contracts JSDoc can express, checked more thoroughly, plus features JSDoc can’t express as cleanly (generics, more precise narrowing):
function calculateTotal(items: { price: number; quantity: number }[], taxRate: number): number {
const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return subtotal * (1 + taxRate);
}
Type narrowing is TypeScript refining a broader type to a more specific one based on a runtime check already in the code:
function describe(value: string | number) {
if (typeof value === 'string') {
return value.toUpperCase(); // narrowed to string here — .toUpperCase() is valid
}
return value.toFixed(2); // narrowed to number here — .toFixed() is valid
}
Designing a package’s public surface
What a package exports is its contract with everyone who depends on it — changing it is a breaking change for them, whether or not that was the intent:
{
"name": "my-lib",
"version": "2.3.1",
"exports": {
".": "./dist/index.js",
"./utils": "./dist/utils.js"
}
}
The exports field explicitly declares what’s public — anything not listed isn’t importable by
consumers at all, which is what makes internal refactoring of unexported files safe without
breaking anyone.
| Segment | Example bump | Means |
|---|---|---|
| Major | 2.3.1 → 3.0.0 | A breaking change — something a consumer relies on now behaves differently or is gone |
| Minor | 2.3.1 → 2.4.0 | A backward-compatible new feature — nothing existing breaks |
| Patch | 2.3.1 → 2.3.2 | A backward-compatible bug fix — behavior gets closer to what was documented, nothing new |
A changelog documenting what actually changed per version — ideally generated or at least reviewed alongside every release — is what lets a consumer decide whether upgrading is safe without reading the full diff themselves.
Check your understanding
Or reveal the answer without checking
Answer:No — JSDoc is editor/tooling information only; it adds no runtime check at all
JSDoc (and TypeScript) provide compile-time/editor-time information, not runtime enforcement. process(null) still throws a TypeError reading .email of null regardless of any type annotation — a real runtime check is a separate, necessary thing.
Or reveal the answer without checking
Answer:Major (2.3.1 → 3.0.0) — any consumer importing the old name now breaks
Any consumer that imports formatDate now has a broken import — the public surface changed in a way that breaks existing code, which is exactly what a major version bump signals, regardless of how small the change feels internally.
Practice: warm-up
Write a JSDoc comment for this function describing its parameter and return types accurately.
function findUserById(users, id) {
return users.find((user) => user.id === id);
}
Stuck? Reveal one hint at a time.
Hint 1
users is an array of some user shape — { id, name } is a reasonable minimal guess if no fuller shape is given.
Hint 2
find can return undefined if nothing matches — the return type should reflect that possibility, not just the "found" case.
Reveal a reference solution
Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.
/**
* Find a user by id.
* @param {Array<{ id: number, name: string }>} users
* @param {number} id
* @returns {{ id: number, name: string } | undefined}
*/
function findUserById(users, id) {
return users.find((user) => user.id === id);
}Practice: apply it
A package’s package.json currently has "version": "1.4.2". For each change, name the correct
next version: (1) fixing a bug where a function returned the wrong sign for negative numbers,
(2) adding a new optional parameter with a default that preserves old behavior, (3) removing a
previously-exported function nobody uses internally anymore.
Stuck? Reveal one hint at a time.
Hint 1
A bug fix that doesn’t add anything new, and doesn’t change any documented, correct behavior, is the smallest kind of bump.
Hint 2
An optional addition that doesn’t change existing behavior for anyone not using it yet is the middle kind of bump.
Hint 3
Removing something a consumer could have been relying on — even if the maintainers think nobody uses it — is the largest kind of bump, since maintainers can’t be certain about every consumer.
Reveal the answers
Try the problem yourself before reading this. There is often more than one reasonable approach — treat this as one worked example, not the only correct answer.
1. Bug fix (wrong sign) -> 1.4.3 (patch)
2. New optional parameter, safe default -> 1.5.0 (minor)
3. Removing an exported function -> 2.0.0 (major) — a consumer might still import it,
regardless of what the maintainers believe usage looks like.Summary
- A JSDoc comment gives editor tooling a function’s real contract — parameter and return types — with no build step required; TypeScript adds a dedicated, more thorough type system on top of the same idea, adoptable gradually rather than all at once.
- Type narrowing refines a broader type to a more specific one based on a runtime check already present in the code.
- A type is a compile-time/editor-time guarantee only — it enforces nothing once the code actually runs, so data crossing a real boundary still needs runtime validation.
- A package’s
exportsfield is its actual public contract; anything not listed is safe to refactor internally without breaking consumers. - Semantic versioning is a promise about observable behavior, not an internal-effort measurement — a small-feeling change can still be a breaking (major) change if it changes what existing consumers depend on.