Command Palette

Search for a command to run...

Hectal
PHASE 11Intermediate ~22 min· topic 6 of 6

Topic 11.6

Type-Checking JavaScript

In one line

TypeScript can check plain .js files too. With allowJs and checkJs (or // @ts-check in one file) it reads types from JSDoc comments, so you can add safety to an old JavaScript project one file at a time, without renaming anything.

Think of it like this

A teacher marks homework written in pencil on paper. The pupils don't have to rewrite their homework on a computer first. She reads what is there, and writes small notes in the margin. **checkJs** is that teacher: the files stay JavaScript, and TypeScript reads them, using the notes you left in comments (JSDoc) as the types, and tells you where it sees a problem.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

allowJs
Let TypeScript include .js files in the project.
checkJs
Type-check every .js file, using JSDoc comments as the types.
// @ts-check
A comment on the first line that turns checking on for that one file.
JSDoc
A comment style (/** … */ with @param tags) for documenting JavaScript.
@typedef
A JSDoc tag that gives a name to a type, like type in TypeScript.
@template
A JSDoc tag that declares a generic type parameter.
@ts-expect-error
A comment that silences the next line's error and fails if there is none.
emitDeclarationOnly
Output only .d.ts files.

Step by step

01A JavaScript file with types in comments

This is src/math.js, plain JavaScript. The comments carry the types. [rate] means optional, with a default.

src/math.jswhole filets
/**
 * Add tax to a price.
 * @param {number} price - the price before tax
 * @param {number} [rate] - tax rate, default 0.18
 * @returns {number}
 */
export function withTax(price, rate = 0.18) {
  return price + price * rate;
}

/** @typedef {{ id: number, name: string, tags?: string[] }} User */

/** @type {User} */
export const demo = { id: 1, name: "Asha" };

/**
 * @template T
 * @param {T[]} items
 * @returns {T | undefined}
 */
export function first(items) {
  return items[0];
}

02Turn the checker on

The tsconfig allows and checks JavaScript. Nothing else changes: still no build step for the .js.

tsconfig.jsonwhole filejson
{
  "compilerOptions": {
    "module": "nodenext",
    "target": "esnext",
    "allowJs": true,
    "checkJs": true,
    "strict": true,
    "noEmit": true,
    "types": []
  },
  "include": ["src"]
}

03Use it wrongly in another JS file

main.js contains four mistakes that JavaScript would run without complaint. Real tsc 7.0.2 output follows the code.

src/main.jswhole filets
import { withTax, first, demo } from "./math.js";

console.log(withTax("100"));
const n = first([1, 2, 3]);
console.log(n.toFixed(1));
demo.nmae = "x";
/** @type {import("./math.js").User} */
const u = { id: "2", name: "B" };

// src/main.js(3,21): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
// src/main.js(5,13): error TS18048: 'n' is possibly 'undefined'.
// src/main.js(6,6): error TS2339: Property 'nmae' does not exist on type 'User'.
// src/main.js(8,13): error TS2322: Type 'string' is not assignable to type 'number'.

04Only some files: // @ts-check

Remove checkJs from the tsconfig: the same project prints nothing (exit 0). Put // @ts-check on the first line of main.js and its errors come back (the real run showed the same messages, with line numbers shifted by one). Files without the comment stay unchecked.

05Get declarations out of the JavaScript

With allowJs, checkJs, declaration, emitDeclarationOnly and outDir: "types", tsc wrote types/math.d.ts (real output):

types/math.d.tswhole filets
/**
 * Add tax to a price.
 * @param {number} price - the price before tax
 * @param {number} [rate] - tax rate, default 0.18
 * @returns {number}
 */
export declare function withTax(price: number, rate?: number): number;
export type User = {
    id: number;
    name: string;
    tags?: string[];
};
/** @typedef {{ id: number, name: string, tags?: string[] }} User */
/** @type {User} */
export declare const demo: User;
/**
 * @template T
 * @param {T[]} items
 * @returns {T | undefined}
 */
export declare function first<T>(items: T[]): T | undefined;

Try it yourself

  1. 1

    Silence with a reason

    In the second example, put // @ts-expect-error on the line above console.log(withTax("100")). That error vanishes. Now fix the call properly: the comment itself becomes an error (TS2578: Unused '@ts-expect-error' directive), which is what makes it safer than @ts-ignore.

  2. 2

    Guard the undefined

    Change the last line to console.log(n?.toFixed(1)); or add if (n === undefined) throw new Error("empty"); before it. Predict the Types panel: TS18048 is gone.

  3. 3

    Plan a migration

    Think of a JavaScript project you know. Write the order you would add // @ts-check to its files: the leaves first (utils with no imports), then the files that import them.

Code & diagrams

In a .ts file JSDoc is documentation, not types

The editor shows the JSDoc text on hover and strikes through withTax because of @deprecated (not an error, a suggestion). The {string} in the second comment does nothing: the TypeScript annotation wins. In a .js file it would be the other way round.

Console

(nothing was printed)

Expected console output

118.00
The same code a JS file would hold: what checkJs finds

With checkJs and JSDoc the JavaScript version of this file gets exactly these messages. JavaScript itself prints 10018 and 1.0 and is happy; the checker is not. Fix both lines: withTax(Number("100")) and n?.toFixed(1).

Console

(nothing was printed)

Expected console output

10018
1.0
Project: allowJs without checkJs, then // @ts-check (real tsc 7.0.2)bash
# tsconfig: allowJs true, checkJs removed
$ tsc -p . ; echo "exit $?"
exit 0

# add  // @ts-check  as the first line of src/main.js
$ tsc -p .
src/main.js(4,21): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
src/main.js(6,13): error TS18048: 'n' is possibly 'undefined'.
src/main.js(7,6): error TS2339: Property 'nmae' does not exist on type 'User'.

# a Closure-style function type in JSDoc (src/enum.js, with // @ts-check)
#   /** @type {function(string): number} */
#   export const len = (s) => s.length;
# TypeScript 6.0.3 prints nothing for enum.js; TypeScript 7.0.2 prints:
src/enum.js(5,20): error TS1005: '}' expected.
src/enum.js(6,21): error TS7006: Parameter 's' implicitly has an 'any' type.

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Call a JSDoc-typed function with the wrong type

In a JS project with checkJs, call withTax("100") where the JSDoc says @param {number} price.

terminal
$ tsc -p .
── what you'll see ──
src/main.js(3,21): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.

Break #2

A Closure-style function type on TypeScript 7

Write /** @type {function(string): number} */ above an arrow function in a checked .js file.

terminal
$ tsc -p . (TypeScript 7.0.2)
── what you'll see ──
src/enum.js(5,20): error TS1005: '}' expected.

Myth vs fact

Myth

Type-checking JavaScript means the JavaScript gets compiled.

Fact

With noEmit nothing is written. The .js file runs exactly as before; TypeScript only reads and reports.

Myth

JSDoc types are weaker than TypeScript types.

Fact

They use the same type system and the same checker, with a clunkier syntax. A few things (like enum, the interface keyword and constructor parameter properties) are only available in .ts files.

Myth

allowJs alone checks my JavaScript.

Fact

It only includes the files. Add checkJs, or // @ts-check in a file, to get errors.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Without strict, .js files get very lenient treatment (noImplicitAny off means unannotated parameters are any). With strict: true, every un-annotated function parameter in a checked JS file is TS7006, as the Closure example shows. That is the practical cost of strict JS checking: you must write the JSDoc.

  • ▸

    Some projects choose JSDoc on purpose even for new code: no build step, .js files run as written in Node and the browser, and tsc --noEmit still checks them. The .d.ts files generated from JSDoc (previous section) give consumers full types.

  • ▸

    7.0 has no programmatic API yet, so tools that analyse JSDoc through the compiler API use TypeScript 6 side by side (the tsc6 executable from @typescript/typescript6). Command-line checking uses the fast native tsc.

Remember this

  1. 1

    The problem this solves: an old JavaScript project cannot be rewritten in TypeScript overnight, but its bugs (wrong argument types, typos) are exactly what TypeScript finds. These settings give you checking first and a rewrite later, if ever. **allowJs** lets TypeScript include .js files in the program (so .ts files can import them and the compiler can read them). By itself it doesn't report errors in them. **checkJs** adds type checking to every .js file. If you only want it for some files, leave checkJs off and put the comment // @ts-check on the first line of those files (real run: with allowJs only, a file with a clear mistake reported nothing; the same file with // @ts-check reported three errors). The opposite, // @ts-nocheck, switches checking off for one file. You can also silence one line with // @ts-expect-error.

  2. 2

    JSDoc is a comment format: /** … */ blocks with tags like @param and @returns. In a .ts file the types are written in the code and JSDoc is only documentation (the live example shows a @param {string} tag being ignored while price: number rules). In a .js file there is no other place to put types, so TypeScript reads the JSDoc tags as types: @param {number} price, @returns {number}, @type {string[]}.

  3. 3

    The tags you will use most: @param {Type} name, with [name] for an optional one and [name=default]; @returns {Type}; @type {Type} on a variable; @typedef {{ id: number, name: string }} User to name a type (it behaves like type User = …, and in the real test the generated .d.ts contained export type User = { id: number; name: string; tags?: string[]; }); @template T for generics; and import("./math.js").User to use a type from another file. Everything you know from Phases 1 to 10 works inside the braces: unions, generics, object types, Promise<T>.

  4. 4

    What it catches, in a real run of tsc 7.0.2 on math.js, main.js with checkJs: withTax("100") is TS2345 (string passed for a number), first([1,2,3]).toFixed(1) is TS18048: 'n' is possibly 'undefined' (the function returns T | undefined), demo.nmae = "x" is TS2339 (typo) and an object with id: "2" where a number is declared is TS2322. The JavaScript is unchanged and still runs. Compare with the .ts version in the second live example: identical messages.

  5. 5

    You can also generate types from JavaScript: with allowJs, declaration and emitDeclarationOnly, tsc turns the JSDoc into a .d.ts (real output: export declare function withTax(price: number, rate?: number): number;, the optional rate coming from @param {number} [rate]). This lets a JavaScript library ship real TypeScript types without being rewritten. (Recall from Topic 11.3: that .d.ts is what the types condition in package.json points to.)

  6. 6

    A path for migration. Turn on allowJs, then add // @ts-check to files one by one, starting with the leaves (files that import nothing from your own project, but that others import). Fix what it finds, add JSDoc types to exported functions. Later switch on checkJs for everything, and rename files to .ts when you want TypeScript syntax. This is how large codebases moved to TypeScript without a stop-the-world rewrite. Note that .js files support only a limited set of type features: no interface keyword (use @typedef) and no type annotations in the code itself.

  7. 7

    What TypeScript 7.0 changed for JSDoc in JS files: the JSDoc handling was rewritten for the native compiler, so some Closure-compiler-era forms are no longer understood. In a real comparison, a file with /** @type {function(string): number} */ reported nothing in 6.0.3 but, in 7.0.2, src/enum.js(5,20): error TS1005: '}' expected. followed by TS7006: Parameter 's' implicitly has an 'any' type. for the function that depended on it. Write function types the TypeScript way: @type {(s: string) => number}. Also, @enum is no longer given special meaning, and postfix ! types are gone. If your JS project used those, change them before you upgrade.

Explain it without notes

01

What is the difference between allowJs, checkJs and // @ts-check?

02

How does TypeScript get types for a JavaScript file?

03

How would you migrate a large JavaScript codebase to TypeScript gradually?

04

What changed for JSDoc in TypeScript 7.0?

Practice

01

Add JSDoc to this function so that checkJs rejects repeat("ab", "3") and accepts repeat("ab"): function repeat(text, times = 2) { return text.repeat(times); }.

02

Write the JSDoc @typedef for a Point with numbers x and y, and a function distance(a, b) using it that returns a number.

Trade-offs

  • ↔

    JSDoc keeps the zero-build workflow and allows gradual adoption, but the comment syntax is more verbose than TypeScript and some features aren't available. For a codebase that will live for years and grow, .ts files are usually the better end state.

  • ↔

    Strict checking of old JavaScript surfaces many findings at once. Turn it on per file with // @ts-check so the team can fix findings in small pull requests.

Done when you can

  • I can explain allowJs, checkJs and // @ts-check.

  • I can write @param, @returns, @type, @typedef and @template.

  • I can read the errors checkJs reports and fix them.

  • I can generate a .d.ts from JSDoc.

  • I know what JSDoc forms TypeScript 7.0 stopped understanding.