Types for JavaScript: JSDoc & TypeScript
Learn how JSDoc, checkJs, TypeScript, and declaration files catch JavaScript type mistakes before runtime while keeping validation honest.
- 01Annotate JavaScriptUse JSDoc tags such as
@param,@returns,@typedef,@property,@template, and@import. - 02Run a checker before runtimeEnable
// @ts-check,checkJs, and strict TypeScript to catch editor squiggles before users see crashes. - 03Choose the right boundaryKnow when TypeScript, declaration files, generated
.d.ts, and runtime validation each protect real code.
Catch type mistakes before users do
JavaScript is dynamically typed: values carry their type at runtime, and variables can hold different kinds of values over time. That flexibility is powerful, but it allows bugs such as a misspelled property, a missing callback, or undefined is not a function to wait until a user clicks the unlucky path.
Static type checking is a tool reading your code before it runs. It builds a model of values, parameters, properties, and return types, then reports type mistakes as editor squiggles or compiler diagnostics. It does not execute your program.
This lesson connects back to data types, checking types, and type coercion. Runtime JavaScript still uses typeof, Array.isArray, truthiness, and guards. The new tool is a checker that can warn before the runtime has a chance to fail.
A proofreader cannot guarantee that an article is true, but they can catch a missing word, mismatched name, or impossible sentence before readers see it. Static types play that early-warning role for values and calls.
- In real life: The proofreader marks a missing word before printing
- In JavaScript: The checker marks a missing property before running
- In real life: The writer still owns the facts
- In JavaScript: Runtime validation still checks outside data
- In real life: A style guide makes review consistent
- In JavaScript: JSDoc, TypeScript, and declarations give the checker shared rules
Where the analogy stops: A proofreader understands meaning better than a type checker. Types catch shape and flow mistakes; they do not prove business logic, accessibility, or security requirements.
const lesson = { title: "Types", onComplete: undefined,}; lesson.onComplete();A dynamic typing recap matters because it tells you where static types fit. They are not a replacement for functions, objects, linting, development environment setup, or transpilers and polyfills. They are one more feedback loop: fast, local, and focused on shapes.
| Tool | What it catches | What it cannot prove |
|---|---|---|
JSDoc + checkJs | Keep .js files and add comments. Great for gradual typing and libraries that do not want a build step. | Comments can get verbose; advanced types are harder to read than TypeScript syntax. |
| TypeScript files | Use .ts/.tsx syntax, inference, strict mode, unions, generics, and rich editor tooling. | Types are erased. You still compile, strip, or run with a compatible runtime. |
| Runtime validation | Use real JavaScript checks at API, form, storage, and URL boundaries. | It protects actual data, but it does not explain every internal call site while you type. |
JSDoc: types in JavaScript comments
COMMENTS AS TYPESJSDoc is the gentlest path for a JavaScript codebase. You keep .js files and add comments that TypeScript-powered editors understand. The comments are ignored by the JavaScript runtime, but // @ts-check or project-wide checkJs lets the checker use them.
| Tag | Purpose | Example |
|---|---|---|
@param {Type} name | Types a function parameter. | @param {number} price |
@returns {Type} | Types the value a function returns. | @returns {string} |
@type {Type} | Types a variable, expression, or property. | /** @type {CourseCard} */ |
@typedef + @property | Names object shapes for reuse. | title, lessons, optional status |
[name] or [name=default] | Marks optional and default parameters or properties. | @param {number} [limit=10] |
A | B | Describes a union of possible types. | string | number |
@template T | Defines a generic type variable. | first<T>(items): T | undefined |
@import {Type} from "./file" | Imports a type name only inside JSDoc comments in TypeScript 5.5+. | No runtime import is emitted. |
Object shapes, optional values, defaults, and unions
The next example declares a reusable CourseCard object shape with @typedef and @property. Square brackets make status optional and document its default. The prefix parameter accepts a string | number union, so the function narrows it with typeof.
// @ts-check/** * @typedef {object} CourseCard * @property {string} title * @property {number} lessons * @property {"draft" | "published"} [status="draft"] */ /** * @param {CourseCard} course * @param {string | number} [prefix="Course"] * @returns {string} */function summarize(course, prefix = "Course") { const label = typeof prefix === "number" ? "#" + prefix : prefix; return label + ": " + course.title + " (" + course.lessons + ")";} console.log(summarize({ title: "Types", lessons: 4, status: "published" }, 7));Read the code line by line: the typedef names the object, each property adds a field, the function receives that named shape, and the body uses a normal runtime typeof check because unions describe possibilities, not decisions.
Generics with @template
Generic JSDoc lets one helper keep the type of the values it receives. Here @template T says “whatever item type the caller passes, use the same item type for the return.” The real inferred type displayed later is string | undefined, because arrays can be empty.
first helper// @ts-check/** * @template T * @param {T[]} items * @returns {T | undefined} */function first(items) { return items[0];} const firstName = first(["Ada", "Lin"]);console.log(firstName);Importing types without importing runtime code
TypeScript 5.5 added a JSDoc @import tag. It brings type names into comments without creating a runtime import. You can still use the older inline form {import("./types").Course}, but @import reads more like normal imports when several annotations share a type.
@import type-only comment// @ts-check/** @import {CourseFromTypes} from "./types" */ /** @type {CourseFromTypes} */const importedCard = { title: "JSDoc", lessons: 2 }; console.log(importedCard.title);firstName on line 11string | undefinedimportedCard on line 5CourseFromTypesEvery type string here comes from TypeScript's checker in the test. The lesson stores no guessed inferred types.
Turn on checkJs
REAL DIAGNOSTICSA JSDoc comment becomes a safety net only when the checker is running. Put // @ts-check at the top of one file for a local opt-in, or set allowJs and checkJs in a jsconfig.json or tsconfig.json for the project. strict makes optional values, unknown, and nullish flows much more useful.
{ "compilerOptions": { "allowJs": true, "checkJs": true, "strict": true, "noEmit": true }, "include": ["src/**/*.js"]}// @ts-check/** * @typedef {{ title: string, onComplete?: () => void }} Lesson */ /** @param {Lesson} lesson */function finishLesson(lesson) { lesson.onComplete();} Cannot invoke an object which is possibly 'undefined'.
Switch examples to see stored diagnostics. The browser only renders precomputed results; the lesson test re-runs TypeScript 5.9 and asserts each code, line, and message.
The important shift is timing. Without checking, the optional callback example waits until runtime to throw. With checking, TS2722 is an editor squiggle before you open the browser. With object shapes, the checker can also catch a property that looks plausible but does not exist.
TypeScript at a glance
TYPES ERASETypeScript is JavaScript plus type syntax and a compiler. You can annotate variables, parameters, returns, and object shapes; you also get inference, so many types are learned from values. A type alias can name any type expression, while an interface names an object shape and can be extended or merged. Teams often use both, choosing the clearer one for the job.
in, and inferred result typetype ApiState = | { status: "loading" } | { status: "ready"; user: { name: string } } | { status: "error"; message: string }; function label(state: ApiState) { if (state.status === "ready") { const readyState = state; return readyState.user.name.toUpperCase(); } if ("message" in state) { return state.message; } return "Loading";} const result = label({ status: "ready", user: { name: "Ada" } });console.log(result);The union says ApiState can be loading, ready, or error. The check state.status === "ready" narrows to the ready variant. The "message" in state branch narrows to the error variant. The lesson test asks TypeScript for the type of readyState and result, so the displayed inferred types are not guesses.
Let a function preserve relationships, such as input item type to output item type.
unknown vs anyunknown forces a guard; any turns the checker off for that value.
Enables checks that make optional, nullish, and implicit-any bugs visible.
unknown blocks unsafe property accessTypeScriptfunction readName(payload: unknown) { return payload.name;} type Course = { title: string; lessons: number };const course: Course = { title: "Types", lessons: "four" }; TypeScript does not add runtime checks. Browsers run JavaScript. This lesson site’s editor can run TypeScript examples because it strips the types before previewing them. Node 22.18+ and Node 23.6+ also strip erasable TypeScript syntax by default, but they do not type-check, do not read tsconfig paths, and syntax that needs generated JavaScript, such as enums, parameter properties, import aliases, or runtime namespaces, needs a transform path such as --experimental-transform-types or a full TypeScript runner.
The TC39 Type Annotations proposal is currently Stage 1. Its goal is similar in spirit to type stripping: JavaScript engines would treat compatible annotations as comments while external tools check them. It is a proposal, not a browser feature you can rely on today.
Narrowing meets real runtime data
STEP THROUGHType checkers narrow values by reading your code. Runtime validators narrow values by actually inspecting data. You need both at trust boundaries: fetch, JSON.parse, localStorage, URLs, forms, and messages from another window.
Step through a real runtime validator. Static types help while coding; this guard protects the boundary where unknown data enters.
script
return typeof value === "object" && value !== null && typeof value.title === "string" && Array.isArray(value.lessons) && value.lessons.every((lesson) => typeof lesson === "string");} const payload = JSON.parse('{"title":"Types for JS","lessons":["JSDoc","TypeScript"]}');if (isCourse(payload)) { console.log(payload.title.toUpperCase());} else { console.log("Invalid course data");}The guard is ordinary JavaScript. In TypeScript you would annotate it as a type predicate, such as value is CoursePayload, so the checker learns from the same runtime branch that protects users.
A tiny checker makes static checking concrete: it reads code-like facts and reports mismatches without executing the function being checked.
script
name: "enroll", params: [ { name: "student", type: "string" }, { name: "lessons", type: "number" }, ],}; function checkCall(signature, args) { const errors = []; if (args.length !== signature.params.length) { errors.push(`Expected ${signature.params.length} arguments, got ${args.length}.`); } signature.params.forEach((param, index) => { if (index < args.length && typeof args[index] !== param.type) { errors.push(`${param.name} should be ${param.type}, got ${typeof args[index]}.`); } }); return errors;} const report = checkCall(signature, ["Ada", "4"]);console.log(report.join("\\n") || "Call matches the signature.");The toy checker is intentionally small. Real TypeScript builds a full program, reads declarations, follows imports, understands control flow, and compares rich types. The toy version still makes the core idea concrete: it can report a bad call without running the function being called.
Declaration files: types for JavaScript that already exists
.D.TSA declaration file, ending in .d.ts, describes types without providing the implementation. It is how TypeScript understands many JavaScript packages, browser APIs, Node APIs, globals, and legacy scripts.
.d.ts file for a legacy widget moduleTypeScriptdeclare module "legacy-widget" { export type WidgetOptions = { theme?: "light" | "dark"; onClose?: () => void; }; export function mount(target: Element, options?: WidgetOptions): void;}| Term | Meaning | Where you see it |
|---|---|---|
.d.ts | A file that describes types for JavaScript already supplied elsewhere. | Ships beside a package or under @types/name. |
declare | Says a value, module, or global exists at runtime even though this file only describes it. | declare module "legacy-widget" { ... } |
types field | Points TypeScript at a package's declaration entry file. | "types": "./dist/index.d.ts" |
exports field | Can map type declarations per exported path. | "types": "./dist/index.d.ts" inside an export. |
| DefinitelyTyped | Community declarations published as @types/* packages. | npm i -D @types/node for Node globals. |
| JSDoc declaration emit | TypeScript can read checked .js and emit .d.ts files. | Use allowJs, checkJs, declaration, and emitDeclarationOnly. |
Packages can point at declarations with a top-level types field, or with exports entries that include a types path for each public export. If a package does not ship declarations, DefinitelyTyped may publish a matching @types package.
Generating declarations from JSDoc
JSDoc is not only for editors. TypeScript can read checked JavaScript and emit declaration files for library consumers. The test for this lesson asks TypeScript to emit the declaration below from the lesson’s JSDoc source and compares it to the stored text.
.d.ts from checked JavaScriptJSON{ "compilerOptions": { "allowJs": true, "checkJs": true, "declaration": true, "emitDeclarationOnly": true, "outDir": "types" }, "include": ["src/**/*.js"]}/** * @typedef {object} CourseSummary * @property {string} title * @property {number} lessons *//** * @param {CourseSummary} course * @returns {string} */export function formatCourse(course: CourseSummary): string;export type CourseSummary = { title: string; lessons: number;}; Practical workflow: choose the right feedback loop
SORT ITIn real teams, the answer is rarely “types only.” Use types for call shapes and object contracts, runtime validation for data you do not control, linting for consistency, and tests for behavior. Type systems are excellent at finding impossible operations; tests are excellent at proving important stories still work.
- Start with
// @ts-checkon a risky JavaScript file. - Add JSDoc where inference cannot see enough, especially function boundaries.
- Move to
.tswhen type syntax is clearer than comments. - Keep validators at API and user-input boundaries even after migration.
`course.lenght` when `course` is typed as `{ length: number }``JSON.parse(response).lessons` from an API you do not control`button.addEventListener("click", 42)`- A user submits an empty password string
- A local variable is declared but never used
- A sorted list uses ascending order when the product requires descending order
Sort each situation by the earliest reliable feedback loop. Some real bugs need tests or linting instead of type annotations.
Common misconceptions
- “JSDoc always checks my JavaScript.” JSDoc is just comments until
// @ts-checkorcheckJsasks TypeScript to read it. - “TypeScript validates API data for me.” Types are erased. External data still needs runtime validation.
- “
anyis a convenient fix.” It is sometimes necessary at a boundary, but it also hides mistakes. Preferunknownuntil you narrow. - “A
.d.tsfile implements a module.” It only describes a module that must exist at runtime. - “Node stripping TypeScript means TypeScript is checked.” Type stripping removes erasable syntax; it does not run the compiler’s type analysis.
| Question | Static types | Runtime validation | Lint/tests |
|---|---|---|---|
| When does it run? | Before runtime in an editor, compiler, or CI. | While the program executes. | Lint before runtime; tests execute selected behavior. |
| Best at | Call shapes, properties, nullish values, and impossible operations. | Untrusted data from APIs, users, storage, and URLs. | Style, accessibility rules, behavior, regressions, and requirements. |
| Danger sign | Silencing with any or stale comments. | Trusting typed code to validate unknown data. | Testing only happy paths or linting without understanding. |
Practice exercises
5 EXERCISESRead the code and type the exact text printed by the final line.
// @ts-check
/**
* @typedef {object} CourseCard
* @property {string} title
* @property {number} lessons
* @property {"draft" | "published"} [status="draft"]
*/
/**
* @param {CourseCard} course
* @param {string | number} [prefix="Course"]
* @returns {string}
*/
function summarize(course, prefix = "Course") {
const label = typeof prefix === "number" ? "#" + prefix : prefix;
return label + ": " + course.title + " (" + course.lessons + ")";
}
console.log(summarize({ title: "Types", lessons: 4, status: "published" }, 7));The call passes a valid object and the number 7, so the branch builds #7: Types (4).
Type the diagnostic code that catches the unsafe optional callback call.
// @ts-check
/**
* @typedef {{ title: string, onComplete?: () => void }} Lesson
*/
/** @param {Lesson} lesson */
function finishLesson(lesson) {
lesson.onComplete();
}
TypeScript reports TS2722 for calling the optional callback without checking that it exists.
first outputType the name printed by the JSDoc generic example.
// @ts-check
/**
* @template T
* @param {T[]} items
* @returns {T | undefined}
*/
function first(items) {
return items[0];
}
const firstName = first(["Ada", "Lin"]);
console.log(firstName);The function returns items[0], so the console prints Ada. The inferred type is still string | undefined because an array could be empty.
Predict the output from the TypeScript narrowing example.
const state = { status: "ready", user: { name: "Ada" } };
function label(state) {
if (state.status === "ready") {
return state.user.name.toUpperCase();
}
if ("message" in state) {
return state.message;
}
return "Loading";
}
console.log(label(state));The ready branch narrows the union and returns readyState.user.name.toUpperCase(), so the output is ADA.
A JavaScript package does not ship its own types. What package prefix commonly supplies community declaration files?
npm i -D @types/nodeCommunity declaration packages normally use the @types scope and are maintained through DefinitelyTyped.
Check your understanding
8 QUESTIONSQuestion 1 of 8What does a static type checker do for JavaScript?
Choose an answer to see the explanation.
Question 2 of 8Which real diagnostic does this checked JavaScript produce?
Read the code, then predictJavaScript// @ts-check /** * @typedef {{ title: string, onComplete?: () => void }} Lesson */ /** @param {Lesson} lesson */ function finishLesson(lesson) { lesson.onComplete(); }Choose an answer to see the explanation.
Question 3 of 8What does the JSDoc object-shape example print?
Read the code, then predict// @ts-check /** * @typedef {object} CourseCard * @property {string} title * @property {number} lessons * @property {"draft" | "published"} [status="draft"] */ /** * @param {CourseCard} course * @param {string | number} [prefix="Course"] * @returns {string} */ function summarize(course, prefix = "Course") { const label = typeof prefix === "number" ? "#" + prefix : prefix; return label + ": " + course.title + " (" + course.lessons + ")"; } console.log(summarize({ title: "Types", lessons: 4, status: "published" }, 7));Choose an answer to see the explanation.
Question 4 of 8Which config turns on checking for JavaScript files across a project?
Choose an answer to see the explanation.
Question 5 of 8What does the narrowed TypeScript example print?
Read the code, then predictconst state = { status: "ready", user: { name: "Ada" } }; function label(state) { if (state.status === "ready") { return state.user.name.toUpperCase(); } if ("message" in state) { return state.message; } return "Loading"; } console.log(label(state));Choose an answer to see the explanation.
Question 6 of 8Why prefer
unknownoveranyfor external data?Choose an answer to see the explanation.
Question 7 of 8What is true about Node's built-in TypeScript type stripping?
Choose an answer to see the explanation.
Question 8 of 8What job does a declaration file perform?
Choose an answer to see the explanation.
Key takeaways
- Static type checking reads code before running it; it turns many runtime crashes into editor feedback.
- JSDoc plus
checkJsbrings types to JavaScript files without changing runtime syntax. - TypeScript adds annotations, inference, unions, narrowing, generics, and strict checking, then erases types.
unknownkeeps external data honest;anyturns checking off for that value.- Declaration files describe JavaScript APIs, and runtime validation still protects untrusted data.
Remember the one-liner.
Use types to catch code-shape mistakes early, and use runtime checks to prove unknown data is safe.
Up next: Runtimes.