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
.jsfiles in the project. - checkJs
- Type-check every
.jsfile, 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@paramtags) for documenting JavaScript. - @typedef
- A JSDoc tag that gives a name to a type, like
typein 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.tsfiles.
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.
/**
* 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.
{
"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.
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):
/**
* 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
Silence with a reason
In the second example, put
// @ts-expect-erroron the line aboveconsole.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
Guard the undefined
Change the last line to
console.log(n?.toFixed(1));or addif (n === undefined) throw new Error("empty");before it. Predict the Types panel:TS18048is gone. - 3
Plan a migration
Think of a JavaScript project you know. Write the order you would add
// @ts-checkto its files: the leaves first (utils with no imports), then the files that import them.
Code & diagrams
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.00With 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# 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.
Break #2
A Closure-style function type on TypeScript 7
Write /** @type {function(string): number} */ above an arrow function in a checked .js file.
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,.jsfiles get very lenient treatment (noImplicitAnyoff means unannotated parameters areany). Withstrict: 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,
.jsfiles run as written in Node and the browser, andtsc --noEmitstill checks them. The.d.tsfiles 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
tsc6executable from@typescript/typescript6). Command-line checking uses the fast nativetsc.
Remember this
- 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.jsfiles in the program (so.tsfiles can import them and the compiler can read them). By itself it doesn't report errors in them. **checkJs** adds type checking to every.jsfile. If you only want it for some files, leavecheckJsoff and put the comment// @ts-checkon the first line of those files (real run: withallowJsonly, a file with a clear mistake reported nothing; the same file with// @ts-checkreported three errors). The opposite,// @ts-nocheck, switches checking off for one file. You can also silence one line with// @ts-expect-error. - 2
JSDoc is a comment format:
/** … */blocks with tags like@paramand@returns. In a.tsfile the types are written in the code and JSDoc is only documentation (the live example shows a@param {string}tag being ignored whileprice: numberrules). In a.jsfile there is no other place to put types, so TypeScript reads the JSDoc tags as types:@param {number} price,@returns {number},@type {string[]}. - 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 }} Userto name a type (it behaves liketype User = …, and in the real test the generated.d.tscontainedexport type User = { id: number; name: string; tags?: string[]; });@template Tfor generics; andimport("./math.js").Userto 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
What it catches, in a real run of tsc 7.0.2 on
math.js,main.jswithcheckJs:withTax("100")isTS2345(string passed for a number),first([1,2,3]).toFixed(1)isTS18048: 'n' is possibly 'undefined'(the function returnsT | undefined),demo.nmae = "x"isTS2339(typo) and an object withid: "2"where anumberis declared isTS2322. The JavaScript is unchanged and still runs. Compare with the.tsversion in the second live example: identical messages. - 5
You can also generate types from JavaScript: with
allowJs,declarationandemitDeclarationOnly, tsc turns the JSDoc into a.d.ts(real output:export declare function withTax(price: number, rate?: number): number;, the optionalratecoming from@param {number} [rate]). This lets a JavaScript library ship real TypeScript types without being rewritten. (Recall from Topic 11.3: that.d.tsis what thetypescondition inpackage.jsonpoints to.) - 6
A path for migration. Turn on
allowJs, then add// @ts-checkto 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 oncheckJsfor everything, and rename files to.tswhen you want TypeScript syntax. This is how large codebases moved to TypeScript without a stop-the-world rewrite. Note that.jsfiles support only a limited set of type features: nointerfacekeyword (use@typedef) and no type annotations in the code itself. - 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 byTS7006: 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,@enumis 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
What is the difference between allowJs, checkJs and // @ts-check?
How does TypeScript get types for a JavaScript file?
How would you migrate a large JavaScript codebase to TypeScript gradually?
What changed for JSDoc in TypeScript 7.0?
Practice
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); }.
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,
.tsfiles are usually the better end state. - ↔
Strict checking of old JavaScript surfaces many findings at once. Turn it on per file with
// @ts-checkso the team can fix findings in small pull requests.
Done when you can
I can explain
allowJs,checkJsand// @ts-check.I can write
@param,@returns,@type,@typedefand@template.I can read the errors checkJs reports and fix them.
I can generate a
.d.tsfrom JSDoc.I know what JSDoc forms TypeScript 7.0 stopped understanding.