TypeScript does not read a .js file as a weaker .ts file. It reads it as a TypeScript program with a different syntax for types: types come from what the code does, from JSDoc comments, and from a handful of JavaScript patterns that TypeScript recognizes because so much existing code uses them. Knowing which of the three is at work explains most of the surprises people meet when they first turn on checkJs.
This page is our own account, checked with TypeScript 5.9; the handbook’s page on type checking JavaScript files covers the same ground in reference form.
Which files are read, and which are checked
Two settings decide what happens to a .js file. allowJs puts JavaScript files in the program, so their exports have types; a file named jsconfig.json turns it on by default. checkJs, or a // @ts-check comment at the top of one file, makes TypeScript report errors in them.
A file can be in the program without being checked. Its declarations still provide types: a @param type in an unchecked module is enforced where a checked file calls the function. That is what lets a migration add JSDoc to shared modules first and get the benefit in every file that uses them.
Types from initializers
Without a comment, a variable’s type comes from the value it starts with, as in a .ts file. A let widens a literal to its type (3 becomes number), so assigning a string later is an error. An empty array literal is the loose case: in a .js file it starts as an array of any and accepts whatever is pushed into it.
let retries = 3;
let label = "checkout";
const sizes = ["S", "M", "L"];
retries = "three";error TS2322: Type 'string' is not assignable to type 'number'.
const seen = [];
seen.push("TEE-01", 42);
export { label, sizes, seen };jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["**/*.js"]
} The handbook adds null and undefined to that loose list. With strict on, TypeScript 5.9 follows a variable that starts as null through its later assignments instead, and reports a read before any of them as TS18047. To keep a variable or an empty array to one type, give it a @type tag.
Classes: properties come from this assignments
A class in a .js file does not need field declarations. TypeScript collects every this.name = value in the constructor and the methods, and types the property from the values assigned. An assignment in the constructor gives the property its type; a property assigned only in a method may not exist yet when another method runs, so its type includes undefined:
export class Session {
/** @param {string} user */
constructor(user) {
this.user = user;
}
touch() {
this.lastSeen = new Date();
}
age() {
return Date.now() - this.lastSeen.getTime();error TS2532: Object is possibly 'undefined'.
}
}jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["**/*.js"]
} Declaring the field in the class body with a @type tag, or assigning it in the constructor, settles the type in one place. Before classes, the same pattern lived in constructor functions: a function that assigns to this and is called with new is read as a class in TypeScript 5.9 (the @class page has the details). TypeScript 7.0 no longer reads them that way, so write a class.
Object literals are open-ended
In a .js file, an object literal assigned to a variable can gain properties later, which is how a lot of configuration code is written. The same lines in a .ts file are TS2339, because there the literal’s type is fixed when it is created. Switch to the TypeScript equivalent tab to see the difference:
const options = {};
options.verbose = true;
options.retries = 3;
/** @param {boolean} value */
function setVerbose(value) {
options.verbose = value;
}
setVerbose(false);
export { options };const options = {};
options.verbose = true;error TS2339: Property 'verbose' does not exist on type '{}'.
options.retries = 3;error TS2339: Property 'retries' does not exist on type '{}'.
function setVerbose(value: boolean) {
options.verbose = value;error TS2339: Property 'verbose' does not exist on type '{}'.
}
setVerbose(false);
export { options };jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["**/*.js"]
} The openness ends as soon as you give the variable a type: with /** @type {{ verbose?: boolean }} */ above it, an unknown property is an error in both kinds of file. It is also what makes noImplicitAny matter for objects: with it off, reading a property that an untyped object never had is not reported either, while under strict it is TS2339, as in the .ts file.
Functions carry properties
A function declaration with properties assigned to it afterwards becomes a function with those properties, a pattern for counters, caches and plugin metadata. Both kinds of file allow it, and both check it:
export function nextId() {
nextId.count += 1;
return `id-${nextId.count}`;
}
nextId.count = 0;
nextId();
nextId.reset();error TS2339: Property 'reset' does not exist on type 'typeof nextId'.export function nextId() {
nextId.count += 1;
return `id-${nextId.count}`;
}
nextId.count = 0;
nextId();
nextId.reset();error TS2339: Property 'reset' does not exist on type 'typeof nextId'.jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["**/*.js"]
} When the same property is assigned more than once, the property’s type is the union of everything assigned, which the recipe on functions with properties shows; a @type tag on the first assignment pins it.
CommonJS is read as modules
require() calls are read as imports and module.exports and exports.name assignments as exports, so types cross CommonJS files as they cross ES modules:
const { formatCents } = require("./prices.js");
console.log(formatCents("1999"));error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["**/*.js"]
} Whether a .js file is CommonJS or an ES module is decided the way Node.js decides it when module is nodenext: by the extension (.cjs, .mjs) and the "type" field of the nearest package.json. The recipe on CommonJS and ES module exports covers the export forms. TypeScript 7.0 reads fewer of the dynamic CommonJS patterns (mixing module.exports = with module.exports.name =, aliases of module.exports); its release page lists them.
Which comments are JSDoc
TypeScript reads a comment as JSDoc only when it starts with /**. A /* comment with the same tags inside is an ordinary comment:
/* @param {string} title */
export function slugify(title) {error TS7006: Parameter 'title' implicitly has an 'any' type.
return title.toLowerCase().replace(/\s+/g, "-");
}jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["**/*.js"]
} Of the /** */ comments above a declaration, only the last one counts. A description in its own comment after the comment that holds the tags hides the tags:
/** @param {number[]} prices */
/** Adds up a list of prices. */
export function total(prices) {error TS7006: Parameter 'prices' implicitly has an 'any' type.
let sum = 0;
for (const price of prices) sum += price;
return sum;
}jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["**/*.js"]
} Inside the comment, the braces hold TypeScript’s type syntax (plus a few older Closure forms), and tags that TypeScript does not know are kept for hovers but change nothing; the tag reference lists which tags are checked. In .ts files, tsc reads only the JSDoc comments that contain @see or @link since TypeScript 5.3, because types there come from the syntax.
Parameters and arguments
An untyped parameter in a .js file is optional: calling with fewer arguments is fine, because so much JavaScript relies on it. Give the parameter a @param type and it becomes required, as in a .ts file; mark it optional with brackets, @param {string} [greeting].
function greet(name, greeting) {
return (greeting || "Hello") + ", " + (name || "you");
}
/**
* @param {string} name
* @param {string} greeting
*/
function greetTyped(name, greeting) {
return greeting + ", " + name;
}
greet();
greet("Ada");
greetTyped("Ada");error TS2554: Expected 2 arguments, but got 1.jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": false,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["**/*.js"]
} The example turns strict off, because under noImplicitAny the untyped parameters would be errors themselves. A function that reads arguments accepts any number of arguments, also without a type (rest parameters shows it).
Missing type arguments become any
JavaScript has no syntax for type arguments, so where TypeScript cannot infer one it uses any in a .js file (a .ts file uses unknown or the parameter’s constraint). A bare Array in a JSDoc type is Array<any>, a class extended without @extends gets any for the base class’s type parameters, and a generic function called with nothing to infer from returns any:
/**
* @template T
* @returns {{ items: T[] }}
*/
export function createStore() {
return { items: [] };
}
// T has no inference source here, so in a .js file it becomes any: nothing is checked.
const loose = createStore();
loose.items.push(1, "two");
/** @type {{ items: number[] }} */
const counts = createStore();
counts.items.push(1);
counts.items.push("two");error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
export { loose, counts };jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["*.js"]
} What .js allows that .ts does not, and the reverse
In a .js file | In a .ts file |
|---|---|
| An object literal can gain properties after it is created | Its type is fixed; new properties are TS2339 |
| An untyped parameter is optional | Every parameter is required unless marked ? |
A value’s name works as a type: {defaults} means typeof defaults | TS2749; write typeof defaults |
A function assigning to this and called with new is a class | Write a class |
Type annotations, enum, abstract, as are errors (TS8xxx) | Supported syntax |
first<string>(list) is two comparisons | A call with a type argument |
The last two rows are the other direction: TypeScript-only syntax in a .js file is an error with a code from the 8000s that names the JSDoc to use instead (TS8010 for annotations), and a call with type arguments parses as first < string > (list):
/**
* @template T
* @param {T[]} items
* @returns {T | undefined}
*/
export function first(items) {
return items[0];
}
/** @type {unknown[]} */
const rows = ["TEE-01"];
const sku = first<string>(rows);error TS2365: Operator '>' cannot be applied to types 'boolean' and 'unknown[]'.error TS2693: 'string' only refers to a type, but is being used as a value here.
export { sku };jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": []
},
"include": ["*.js"]
} The value-as-type row is one of several leniencies that TypeScript 7.0 removes: its notes describe .js files being analyzed like .ts files with a different syntax for types. Code written in the style of the right-hand column already checks the same in both, which is why the pages on this site write typeof even where 5.9 would not require it. The quirks of JSDoc collects the rest of the surprises, and JSDoc vs TypeScript files compares the two side by side.
Questions
Does TypeScript read JSDoc in a file that is not checked?
Yes. A .js file in the program provides types to the files that import it, checked or not. In TypeScript 5.9, a @param type in an unchecked module is enforced at the call sites in checked files.
Why does the same code pass in a .js file and fail in a .ts file?
A few JavaScript patterns are read more loosely in .js files: object literals that gain properties later, parameters without types (optional rather than required), a value's name used as a type. The .ts file has syntax for each of them, so TypeScript expects it there.
Sources
- Type Checking JavaScript Files · TypeScript handbook
- JSDoc Reference · TypeScript handbook
- Modules: Theory (module format detection) · TypeScript handbook
- CHANGES.md: JavaScript in the native port · microsoft/typescript-go on GitHub
Last reviewed 21 October 2026.