Skip to main content
TypeScript · lesson 23 of 25
TypeScript intermediate Lesson 23 of 25

Type-Checking JavaScript with JSDoc and checkJs

@ts-check and JSDoc types catch real errors in plain .js files, in a mixed allowJs/checkJs project, down to the .d.ts files it can emit.

By SkillByExample · Published

typescript javascript jsdoc

Examples run on TypeScript 7.0.2 and Node v24.18.1. The single-file examples need no tsconfig.json; the mixed-project example runs in its own folder with "type": "module" in package.json.

tsc Won’t Look at a .js File Unless You Ask

By default, tsc treats a plain JavaScript file as out of scope, even when you name it directly.

console.log("hello from plain JS");
$ npx tsc --noEmit plain.js
error TS6504: File 'C:/tmp/ts-gap/plain.js' is a JavaScript file. Did you mean to enable the 'allowJs' option?
  The file is in the program because:
    Root file specified for compilation
error TS6504: File 'plain.js' is a JavaScript file. Did you mean to enable the 'allowJs' option?
  The file is in the program because:
    Root file specified for compilation

allowJs is the switch that lets .js files into the program at all — without it, tsc won’t even parse one.

@ts-check Finds a Real Bug; node Doesn’t

allowJs alone still doesn’t check a file’s code. A // @ts-check comment at the top turns checking on for that one file, using JSDoc annotations as the type information.

// @ts-check

/**
 * @param {number} amount
 * @param {number} rate
 * @returns {string}
 */
function formatDiscount(amount, rate) {
  return `$${(amount * rate).toFixed(2)}`;
}

console.log(formatDiscount(10, 0.25));
console.log(formatDiscount(10, "0.25"));
$ npx tsc --noEmit --module nodenext --allowJs price.js
price.js(13,32): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
$ node price.js
$2.50
$2.50

The @param {number} rate comment is what makes "0.25" an error — tsc reads it exactly like a TypeScript parameter annotation. node has no idea any of this exists: it runs the file as-is, and amount * rate — 10 * "0.25" — coerces through JavaScript’s usual numeric coercion to 2.5, printing the same $2.50 either call. The bug is real (a caller passed the wrong type), it’s just invisible until something checks for it.

Remove the // @ts-check line from the same file, leaving the JSDoc comment in place, and the error disappears — tsc no longer checks the file’s code at all, only allowJs is in effect:

$ npx tsc --noEmit --module nodenext --allowJs price-nopragma.js

No output means no errors, even though formatDiscount(10, "0.25") is still the second call in that file. checkJs in tsconfig.json does for every file in a project what // @ts-check does for one.

@typedef Gives JavaScript Object Types

@typedef and @property describe an object shape once, so later JSDoc comments can reference it by name instead of spelling it out again.

// @ts-check

/**
 * @typedef {object} User
 * @property {string} name
 * @property {number} age
 */

/**
 * @param {User} user
 * @returns {string}
 */
function greet(user) {
  return `Hello, ${user.name} (${user.age})`;
}

/** @type {User} */
const ada = { name: "Ada", age: 36 };

console.log(greet(ada));
console.log(greet({ name: "Grace" }));
$ npx tsc --noEmit --module nodenext --allowJs user.js
user.js(21,19): error TS2741: Property 'age' is missing in type '{ name: string; }' but required in type 'User'.

greet({ name: "Grace" }) is missing age, and the error names User by the same identifier the @typedef declared — not an inlined object type. Remove that call:

$ node user.js
Hello, Ada (36)

A Mixed Project: .ts Importing a Checked .js Module

Set allowJs and checkJs in tsconfig.json, instead of // @ts-check per file, and a .ts file importing a .js module gets the same checking both ways. The project is tsconfig.json at the root, src/math.js, and src/index.ts importing from it.

{
  "compilerOptions": {
    "module": "nodenext",
    "target": "esnext",
    "allowJs": true,
    "checkJs": true,
    "strict": true,
    "noEmit": true
  },
  "include": ["src"]
}
/**
 * @param {number[]} values
 * @returns {number}
 */
export function average(values) {
  return values.reduce((sum, n) => sum + n, 0) / values.length;
}
import { average } from "./math.js";

const scores: number[] = [88, 92, 79];
console.log(average(scores));
console.log(average(["88", "92"]));
$ npx tsc
src/index.ts(5,22): error TS2322: Type 'string' is not assignable to type 'number'.
src/index.ts(5,28): error TS2322: Type 'string' is not assignable to type 'number'.

No // @ts-check anywhere — checkJs in the shared tsconfig.json means src/math.js is checked as a file in its own right, and src/index.ts sees average’s parameter as number[], straight from the JSDoc, exactly as if math.js were math.ts. Remove the bad call:

$ npx tsc
$ node src/index.ts
86.33333333333333

Emitting a .d.ts From Plain JavaScript

Adding declaration and emitDeclarationOnly lets tsc write a .d.ts file straight from a JavaScript file’s JSDoc, with no .ts source anywhere.

$ npx tsc --ignoreConfig --allowJs --declaration --emitDeclarationOnly --module nodenext --outDir types src/math.js
$ cat types/math.d.ts
/**
 * @param {number[]} values
 * @returns {number}
 */
export declare function average(values: number[]): number;

That .d.ts is what a downstream TypeScript project would see if math.js were published as a package — the JSDoc comment carried straight through, and the parameter typed number[] instead of implicitly any. This is also how many packages on npm ship typed JavaScript without a build step: write .js with JSDoc, publish the generated .d.ts alongside it.

Practice

1. Write toFahrenheit(celsius), tagged @returns {number}, whose body actually returns a template-literal string like `${f}°F`. Find the mismatch with tsc, then fix the JSDoc so it matches what the function really returns.

Solution
// @ts-check

/**
 * @param {number} celsius
 * @returns {number}
 */
function toFahrenheit(celsius) {
  return `${celsius * 1.8 + 32}°F`;
}

console.log(toFahrenheit(100));
$ npx tsc --noEmit --module nodenext --allowJs returns-mismatch.js
returns-mismatch.js(8,3): error TS2322: Type 'string' is not assignable to type 'number'.
$ node returns-mismatch.js
212°F

tsc catches the mismatch between the declared @returns {number} and the return statement, which actually produces a string. node doesn’t care either way — it runs the function and prints "212°F", which would break any caller that tried to do arithmetic on the result expecting a number. Fix the tag to describe reality instead of the function:

$ npx tsc --noEmit --module nodenext --allowJs returns-fixed.js
$ node returns-fixed.js
212°F

Changing @returns {number} to @returns {string} is enough — tsc only checks that the annotation and the code agree, not which one is “right”. A caller reading the JSDoc now gets an accurate type instead of a misleading one.

2. Write a @typedef Order with id (string) and total (number), a receipt function that uses it, and a call that gets total wrong.

Solution
// @ts-check

/**
 * @typedef {object} Order
 * @property {string} id
 * @property {number} total
 */

/**
 * @param {Order} order
 * @returns {string}
 */
function receipt(order) {
  return `Order ${order.id}: $${order.total.toFixed(2)}`;
}

console.log(receipt({ id: "A1", total: 19.5 }));
console.log(receipt({ id: "A2", total: "19.50" }));
$ npx tsc --noEmit --module nodenext --allowJs order.js
order.js(18,33): error TS2322: Type 'string' is not assignable to type 'number'.

Remove the bad call:

$ node order.js
Order A1: $19.50

3. Add a second function, clamp(value, min, max), to math.js, with JSDoc typing all three parameters number. Call it from index.ts with a string argument and confirm checkJs catches it there too.

Solution
/**
 * @param {number[]} values
 * @returns {number}
 */
export function average(values) {
  return values.reduce((sum, n) => sum + n, 0) / values.length;
}

/**
 * @param {number} value
 * @param {number} min
 * @param {number} max
 * @returns {number}
 */
export function clamp(value, min, max) {
  return Math.min(Math.max(value, min), max);
}
import { average, clamp } from "./math.js";

const scores: number[] = [88, 92, 79];
console.log(average(scores));
console.log(clamp(105, 0, 100));
console.log(clamp("105", 0, 100));
$ npx tsc
src/index.ts(6,19): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.

Remove the bad call:

$ npx tsc
$ node src/index.ts
86.33333333333333
100

The error is reported against index.ts, the caller, even though clamp’s types come entirely from a JSDoc comment in math.js — checkJs makes the two files check against each other exactly like two .ts files would.

4. Add declaration and emitDeclarationOnly to the mixed project’s tsconfig.json, without rootDir, and build it. Read the error, then add rootDir and look at math.d.ts.

Solution
{
  "compilerOptions": {
    "module": "nodenext",
    "target": "esnext",
    "allowJs": true,
    "checkJs": true,
    "strict": true,
    "declaration": true,
    "emitDeclarationOnly": true,
    "outDir": "types"
  },
  "include": ["src"]
}
$ npx tsc
tsconfig.json(10,5): error TS5011: The common source directory of 'tsconfig.json' is './src'. The 'rootDir' setting must be explicitly set to this or another path to adjust your output's file layout.
  Visit https://aka.ms/ts6 for migration information.
$ find types -type f
types/src/index.d.ts
types/src/math.d.ts

It’s an error, but tsc still wrote both .d.ts files — just nested under types/src/ instead of flat inside types/, because without rootDir it fell back to treating the whole project tree as the common root. Add "rootDir": "src":

{
  "compilerOptions": {
    "module": "nodenext",
    "target": "esnext",
    "allowJs": true,
    "checkJs": true,
    "strict": true,
    "declaration": true,
    "emitDeclarationOnly": true,
    "rootDir": "src",
    "outDir": "types"
  },
  "include": ["src"]
}
$ npx tsc
$ find types -type f
types/index.d.ts
types/math.d.ts
$ cat types/math.d.ts
/**
 * @param {number[]} values
 * @returns {number}
 */
export declare function average(values: number[]): number;
/**
 * @param {number} value
 * @param {number} min
 * @param {number} max
 * @returns {number}
 */
export declare function clamp(value: number, min: number, max: number): number;

With rootDir explicit, the output lands flat in types/, matching src/’s own layout. math.d.ts has both functions’ JSDoc comments and inferred types, generated from math.js alone — there’s no math.ts anywhere in this project. index.d.ts is unremarkable by comparison: it came from the ordinary .ts source, src/index.ts, the same way any TypeScript file gets a declaration file.

Frequently Asked Questions

Does tsc check .js files by default?
No. tsc refuses a .js file outright with TS6504 unless allowJs is on, and even with allowJs on it only parses the file for things like imports — it doesn't type-check the code inside unless checkJs is also on, or the file starts with a // @ts-check comment.
What does // @ts-check do?
It turns on type checking for that one JavaScript file, using the types tsc can infer plus any JSDoc annotations, without needing checkJs set for the whole project. It only works with allowJs also enabled, since tsc has to be allowed to look inside .js files at all.
Can a TypeScript file import a type-checked JavaScript file?
Yes. With allowJs and checkJs on in tsconfig.json, a .ts file importing a .js function gets that function's JSDoc-derived parameter and return types, exactly as if it had been written in TypeScript. Both files are checked against each other in the same compile.
How do you get a .d.ts file from plain JavaScript?
Set allowJs, declaration and emitDeclarationOnly in tsconfig.json (or pass them on the command line) and run tsc. It reads each .js file's JSDoc comments and writes a matching .d.ts, in outDir if one is set or beside the source otherwise, with the inferred types — usable by any TypeScript project that imports the package.
Does Node run JavaScript with type errors in it?
Yes. node and tsx both simply run JavaScript; neither reads JSDoc comments or cares whether tsc would reject the file. Type-checking JavaScript is a separate, opt-in step — run tsc --noEmit in CI if you want it enforced.