Types for JavaScript code
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:
- 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.
- TypeScript syntax: you write
.tsfiles 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:
// @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:
/** * @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(", ");}@typedefdefines a named type;@propertylists the members of an object type.@param {Type} nametypes 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 Tdeclares a generic, and@type {X}annotates a variable. // @ts-checkat 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:
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]);}12NaNTypeErrorThe 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:
npm install --save-dev typescript @types/nodeHere 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"]}allowJsandcheckJsmake the compiler read and check JavaScript, with or without// @ts-check.noEmitwrites no output files.strictturns 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 themprocessandnode:fsare 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.
$ npx tsctj_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:
// @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:
/** @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"));[ 'ink', 0 ]#7 ABCThe 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,ascasts,import type, the non-null!. - Syntax that needs generated code is refused:
enum, runtimenamespace, constructor parameter properties (constructor(private x: number)) andimport a = require(...). tsconfig.jsonis ignored,.tsfiles insidenode_modulesare 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.
export interface User { id: number; name: string;}A second file holds a function:
export function greet(name: string): string { return `Hello, ${name}`;}The main program imports both and adds its own types:
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.
$ node tj_main.tsHello, Ada (#1)HELLO, ADA (#X)Now three files that are wrong for Node in three ways. The first uses an enum.
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.
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.
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.
$ node tj_enum.ts 2>&1 | grep ErrorSyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode$ node tj_badimport.ts 2>&1 | grep ErrorSyntaxError: The requested module './tj_types.ts' does not provide an export named 'User'$ node tj_wrongtype.ts42 stringThe 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
.jsfiles. 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
.tsin import paths. A package published to npm normally still ships compiled.jsand.d.tsfiles. - 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
tscin CI. - Lying with
ascasts. They silence the checker and do not make anything true. - Using
enum,namespaceor parameter properties in files Node runs directly. Useas constobjects and plain classes. - Forgetting
import type, the.tsextension, or thattsconfig.jsondoes not affectnode file.ts.
Exercises
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):
$ npx tscinventory.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:
$ npx tsc$ node inventory.mjs2#7 12Show 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.
// @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"));2#7 12Save 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:
$ node level.tsHighShow 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.
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]);$ node tj_ex_level.tsHighAdd 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-checkfunction 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):
$ npx tscperson.mjs(2,16): error TS7006: Parameter 'person' implicitly has an 'any' type.Expected output after the fix:
Ada, unknown ageAlan, 41 yearsShow 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.
// @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 }));Ada, unknown ageAlan, 41 yearsSummary
- A static type checker finds type mistakes before the program runs. In JavaScript use it through JSDoc comments, with
// @ts-checkper file orcheckJsintsconfig.json, and runnpx tsc(noEmit,strict,types: ["node"]with@types/node). - JSDoc tags:
@param {Type} name,[name]for optional,@returns,@typedefand@property,@template,@type;@ts-expect-errorsilences 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.tsruns erasable TypeScript by removing the types (stable from Node 24.12 and 25.2). It does no checking and ignorestsconfig.json.enum, runtimenamespaceand parameter properties are refused, imports need the.tsextension, and type-only imports needimport type. - Use
tscwitherasableSyntaxOnlyandverbatimModuleSyntaxto keep the checker in step with Node. - Versions: TypeScript 7.0.2 and
@types/node26.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.