Types for JavaScript code

Intermediate55 min

Get type checking without leaving JavaScript, with JSDoc comments and the TypeScript compiler as a checker, and learn what Node's type stripping does when it runs TypeScript files directly.

What you will learn
  • Describe parameters, results and objects with JSDoc type comments
  • Run the TypeScript compiler as a checker over JavaScript files with tsconfig.json
  • Explain why a type error does not stop a JavaScript program from running
  • Run erasable TypeScript directly with Node and name the syntax it cannot handle
  • Decide when JSDoc is enough and when to move to the TypeScript track
On this page

JavaScript checks types while the program runs. The call area({ width: 3, height: "4" }) is accepted until a line inside area misbehaves, far from where the mistake was made, or never. A static type checker reads the program without running it, follows values from where they are created to where they are used, and reports where the types do not fit. You see the mistake in your editor, and your users never meet it.

You do not have to change language to use a checker. There are two routes:

  1. JSDoc comments: you keep writing plain JavaScript and describe the types in comments. The TypeScript compiler reads them and checks the code. Nothing changes at run time.
  2. TypeScript syntax: you write .ts files with type annotations, and Node runs them directly by removing (stripping) the annotations.

TypeScript has its own track; this lesson covers the JavaScript side.

JSDoc types

A JSDoc comment starts with /**. Tags that start with @ describe the code below it, and a type goes in curly braces. First a type for a rectangle, and a function that uses it:

tj_area.mjs
// @ts-check
/**
* @typedef {object} Rect
* @property {number} width
* @property {number} height
*/
/**
* Area of a rectangle.
* @param {Rect} rect
* @returns {number}
*/
export function area(rect) {
return rect.width * rect.height;
}

A second function takes an array and an optional number:

tj_area.mjs (continued)
/**
* @param {string[]} names
* @param {number} [limit] how many names to keep
* @returns {string}
*/
export function shortList(names, limit = 2) {
return names.slice(0, limit).join(", ");
}
  • @typedef defines a named type; @property lists the members of an object type.
  • @param {Type} name types a parameter. Square brackets, [limit], mark it optional. @returns {Type} types the result.
  • Types use TypeScript’s notation: number, string[], Map<string, number>, string | number, (item: number) => boolean. @template T declares a generic, and @type {X} annotates a variable.
  • // @ts-check at the top asks the checker to look at this file. VS Code and other editors using the TypeScript language service show the errors as you type.

The comments are ordinary comments, so the file runs as before, and a type error does not stop a program. This file misuses both functions:

tj_misuse.mjs
import { area, shortList } from "./tj_area.mjs";
console.log(area({ width: 3, height: "4" }));
console.log(area({ width: 3 }));
try {
shortList("Ada", 2);
} catch (error) {
console.log(String(error).split(":")[0]);
}
Output
12
NaN
TypeError

The first call is right by luck, because JavaScript converts "4". The second gives NaN, and the third crashes, because a string has no join method. A checker reports all three before the program starts.

Running the checker

The checker is the TypeScript compiler, tsc, used in a mode in which it only reports. Install it with the type definitions of Node itself, which describe process, node:fs and the other built-in modules:

Terminal window
npm install --save-dev typescript @types/node

Here is the package.json that results, with a typecheck script added by hand:

{
"name": "tj-proj",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsc"
},
"devDependencies": {
"@types/node": "^26.6.5",
"typescript": "^7.0.2"
}
}

The checker’s options go in tsconfig.json:

{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "esnext",
"module": "nodenext",
"types": ["node"]
},
"include": ["*.mjs"]
}
  • allowJs and checkJs make the compiler read and check JavaScript, with or without // @ts-check.
  • noEmit writes no output files. strict turns on the full set of checks: demanding, but that is where the value is.
  • module: "nodenext" resolves imports the way current Node does. types: ["node"] loads the Node definitions; without them process and node:fs are reported as unknown.

Run npx tsc with no file names, so that it uses tsconfig.json (TypeScript 7 refuses to combine a config file with files named on the command line). The output below is from a real run on 17 October 2026 with TypeScript 7.0.2 on the files above; your editor shows the same errors as underlines.

Terminal window
$ npx tsc
tj_misuse.mjs(3,30): error TS2322: Type 'string' is not assignable to type 'number'.
tj_misuse.mjs(4,18): error TS2741: Property 'height' is missing in type '{ width: number; }' but required in type 'Rect'.
tj_misuse.mjs(6,13): error TS2345: Argument of type 'string' is not assignable to parameter of type 'string[]'.

Each message has the file, line and column, an error code and a sentence. The command exits non-zero, so npm run typecheck can sit in CI next to the tests and the linter from the previous lesson. The checker never changes your files, so you can add types gradually.

Generics, a variable annotation and a union work the same way. This function returns the first item that passes a test, whatever the item type is:

tj_jsdoc.mjs
// @ts-check
/**
* @template T
* @param {T[]} items
* @param {(item: T) => boolean} test
* @returns {T | undefined}
*/
export function findFirst(items, test) {
for (const item of items) {
if (test(item)) return item;
}
return undefined;
}

Then a typed Map, and a parameter that is a number or a string:

tj_jsdoc.mjs (continued)
/** @type {Map<string, number>} */
const stock = new Map([["pen", 12], ["ink", 0]]);
/** @param {string | number} id */
function label(id) {
return typeof id === "number" ? `#${id}` : id.toUpperCase();
}
console.log(findFirst([...stock], ([, count]) => count === 0));
console.log(label(7), label("abc"));
Output
[ 'ink', 0 ]
#7 ABC

The checker knows findFirst returns a map entry or undefined, and that inside the typeof test id is a number in one branch and a string in the other. // @ts-expect-error reason on the line above silences one error, and complains when that line has no error any more.

Running TypeScript files with Node

The second route is to write TypeScript and let Node run it. Requires Node.js 22.18 or later From Node 22.18 (and 23.6), node file.ts just works. Node 22.6 to 22.17 need the flag --experimental-strip-types and print a warning. The feature is stable from Node 24.12 and 25.2.

Node does not compile TypeScript: it replaces the type syntax with blank space, and runs what is left. So:

  • No type checking happens. The checker is still tsc.
  • Only syntax that can be erased works: annotations, interface, type, generics, as casts, import type, the non-null !.
  • Syntax that needs generated code is refused: enum, runtime namespace, constructor parameter properties (constructor(private x: number)) and import a = require(...).
  • tsconfig.json is ignored, .ts files inside node_modules are refused, and local imports must spell the extension: from "./greet.ts".

A small program in three files: a file with a type, a file with a function, and a main program.

tj_types.ts
export interface User {
id: number;
name: string;
}

A second file holds a function:

tj_greet.ts
export function greet(name: string): string {
return `Hello, ${name}`;
}

The main program imports both and adds its own types:

tj_main.ts
import type { User } from "./tj_types.ts";
import { greet } from "./tj_greet.ts";
type Id = number | string;
interface Options {
loud?: boolean;
}
const user: User = { id: 1, name: "Ada" };
function describe(id: Id, options: Options = {}): string {
const text = `${greet(user.name)} (#${id})`;
return options.loud ? text.toUpperCase() : text;
}
console.log(describe(user.id));
console.log(describe("x", { loud: true }));

User has no runtime value, so it is imported with import type, which Node removes entirely.

Terminal window
$ node tj_main.ts
Hello, Ada (#1)
HELLO, ADA (#X)

Now three files that are wrong for Node in three ways. The first uses an enum.

tj_enum.ts
enum Color {
Red,
Green,
}
console.log(Color.Green);

The second imports an interface without import type, so Node looks for a runtime export that does not exist.

tj_badimport.ts
import { User } from "./tj_types.ts";
const user: User = { id: 1, name: "Ada" };
console.log(user.name);

The third lies with a cast, which the checker accepts.

tj_wrongtype.ts
function double(n: number): number {
return n * 2;
}
const input = "21" as unknown as number;
console.log(double(input), typeof input);

The real messages come with a code frame and stack lines, so grep picks out the line that names the error.

Terminal window
$ node tj_enum.ts 2>&1 | grep Error
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode
$ node tj_badimport.ts 2>&1 | grep Error
SyntaxError: The requested module './tj_types.ts' does not provide an export named 'User'
$ node tj_wrongtype.ts
42 string

The last file is the lesson in one line. The cast told the checker that "21" is a number, and Node printed 42 string: the string went into double, and JavaScript converted it. Node removes the types, tsc enforces them, and a cast is a promise only you can keep.

To make tsc agree with Node, Node’s TypeScript page recommends these options (TypeScript 5.8 or later): noEmit, target: "esnext", module: "nodenext", rewriteRelativeImportExtensions, erasableSyntaxOnly and verbatimModuleSyntax. Then tsc reports the enum (TS1294) and the missing import type (TS1484) that Node would find only at run time. --no-strip-types turns stripping off, and --experimental-transform-types, which compiled enum, was removed in Node 26.

Which route should you choose?

  • JSDoc suits plain JavaScript projects, small tools and libraries that publish .js files. No build step, but complex types are awkward in comments.
  • TypeScript with type stripping gives the full syntax and no build step in development, if you keep to erasable syntax and write .ts in import paths. A package published to npm normally still ships compiled .js and .d.ts files.
  • A bigger project or team is usually better served by the TypeScript track.

Both routes use the same checker, so you can switch later.

Common pitfalls

  • Believing that types protect the running program. Validate data from files, JSON, the network and users at run time.
  • Never running the checker. Unchecked types are comments that may be wrong. Put tsc in CI.
  • Lying with as casts. They silence the checker and do not make anything true.
  • Using enum, namespace or parameter properties in files Node runs directly. Use as const objects and plain classes.
  • Forgetting import type, the .ts extension, or that tsconfig.json does not affect node file.ts.

Exercises

ExerciseFix three type errors

In a folder with the package.json and tsconfig.json from this lesson (after npm install), save this as inventory.mjs. It runs, but the checker disagrees. Fix the file so that npx tsc prints nothing, and node inventory.mjs prints the two lines below.

// @ts-check
/** @param {string[]} names */
function count(names) {
return names.length;
}
console.log(count("Ada,Alan"));
/** @type {Map<string, number>} */
const stock = new Map();
stock.set("pen", "12");
const label = (id) => `#${id}`;
console.log(label(7), stock.get("pen"));

Before the fix, npx tsc reports (TypeScript 7.0.2, 17 October 2026):

Terminal window
$ npx tsc
inventory.mjs(7,19): error TS2345: Argument of type 'string' is not assignable to parameter of type 'string[]'.
inventory.mjs(11,18): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
inventory.mjs(13,16): error TS7006: Parameter 'id' implicitly has an 'any' type.

Expected output after the fix:

Terminal window
$ npx tsc
$ node inventory.mjs
2
#7 12
Show solution

Each error names its line. The call needs an array, not a comma-separated string. The map holds numbers, so 12 replaces "12". The arrow function needs a JSDoc @param, which fixes the implicit any.

tj_ex_inventory.mjs
// @ts-check
/** @param {string[]} names */
function count(names) {
return names.length;
}
console.log(count(["Ada", "Alan"]));
/** @type {Map<string, number>} */
const stock = new Map();
stock.set("pen", 12);
/** @param {number} id */
const label = (id) => `#${id}`;
console.log(label(7), stock.get("pen"));
Output
2
#7 12
ExerciseMake Node run it

Save this as level.ts and run node level.ts. Node refuses it. Rewrite it without enum, using a plain object with as const, so that Node prints High.

enum Level {
Low,
High,
}
const level: Level = Level.High;
console.log(Level[level]);

Node’s first line of complaint is SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode. Expected output after the fix:

Terminal window
$ node level.ts
High
Show solution

An as const object keeps the values and their types, and needs no generated code. The type of the same name is the union of the values, 0 | 1. The names array stands in for the reverse lookup that the enum provided.

tj_ex_level.ts
const Level = { Low: 0, High: 1 } as const;
type Level = (typeof Level)[keyof typeof Level];
const level: Level = Level.High;
const names = ["Low", "High"];
console.log(names[level]);
Terminal window
$ node tj_ex_level.ts
High
ExerciseAn object type in JSDoc

Add this to the same folder as person.mjs. The checker reports that the parameter person has an implicit any type. Once you type it, it also notices that the first call misspells a property.

// @ts-check
function greet(person) {
const age = person.age === undefined ? "unknown age" : `${person.age} years`;
return `${person.name}, ${age}`;
}
console.log(greet({ nme: "Ada" }));
console.log(greet({ name: "Alan", age: 41 }));

Define a type Person with @typedef, with a required name string and an optional age number, use it for the parameter, and fix the call. npx tsc must print nothing, and the program must print the two greetings.

Before the fix npx tsc reports one error (TypeScript 7.0.2, 17 October 2026):

Terminal window
$ npx tsc
person.mjs(2,16): error TS7006: Parameter 'person' implicitly has an 'any' type.

Expected output after the fix:

Ada, unknown age
Alan, 41 years
Show solution

In the object notation of a @typedef, age? is optional, so the first call is allowed without it. With the type in place, the misspelling nme is exactly what the checker finds, so the fix is to write name. tsc reports nothing for this solution.

tj_ex_person.mjs
// @ts-check
/** @typedef {{ name: string, age?: number }} Person */
/**
* @param {Person} person
* @returns {string}
*/
function greet(person) {
const age = person.age === undefined ? "unknown age" : `${person.age} years`;
return `${person.name}, ${age}`;
}
console.log(greet({ name: "Ada" }));
console.log(greet({ name: "Alan", age: 41 }));
Output
Ada, unknown age
Alan, 41 years

Summary

  • A static type checker finds type mistakes before the program runs. In JavaScript use it through JSDoc comments, with // @ts-check per file or checkJs in tsconfig.json, and run npx tsc (noEmit, strict, types: ["node"] with @types/node).
  • JSDoc tags: @param {Type} name, [name] for optional, @returns, @typedef and @property, @template, @type; @ts-expect-error silences one line.
  • Types do not exist at run time. A program with type errors still runs; validate outside data.
  • Requires Node.js 22.18 or later node file.ts runs erasable TypeScript by removing the types (stable from Node 24.12 and 25.2). It does no checking and ignores tsconfig.json. enum, runtime namespace and parameter properties are refused, imports need the .ts extension, and type-only imports need import type.
  • Use tsc with erasableSyntaxOnly and verbatimModuleSyntax to keep the checker in step with Node.
  • Versions: TypeScript 7.0.2 and @types/node 26.6.5, checked on 17 October 2026.

Next: the module project, a tested API client that combines fetch, retries, cancellation, the test runner, the linter and this checker.