Type Checking Vanilla JavaScript With JSDoc

TypeScript has become a staple of modern web development, but its syntax isn’t for everyone. The compilation step and unfamiliar type annotations can be a hurdle, especially for small projects or developers who prefer plain JavaScript. Fortunately, the TypeScript compiler can type check standard JavaScript files when you annotate them with JSDoc comments—no syntax changes required.

This approach gives you the safety net of static type checking while keeping your code valid JavaScript that runs directly in browsers or Node.js without a build step.

Project Setup

To get started, you’ll need Node.js and npm installed. Create a new project directory, run npm init, then install TypeScript:

npm i -D typescript

Next, create a tsconfig.json file at the project root to tell TypeScript how to handle your JavaScript code:

{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "moduleResolution": "node",
    "lib": ["es2017", "dom"],
    "allowJs": true,
    "checkJs": true,
    "noEmit": true,
    "strict": false,
    "noImplicitThis": true,
    "alwaysStrict": true,
    "esModuleInterop": true
  },
  "include": [ "script", "test" ],
  "exclude": [ "node_modules" ]
}

The two key options here are allowJs and checkJs, both set to true. These settings enable the compiler to parse and type check plain JavaScript. The include field limits checking to the /script directory, so create that folder and add an index.js file to it.

Adding Types to Functions

Consider a simple addition function:

function add(x, y) {
  return x + y;
}

This works fine with numbers, but because JavaScript is dynamically typed, nothing stops someone from calling it with mixed types:

add('4', 2); // returns '42'

The result is an unintended string concatenation rather than an arithmetic error. You can prevent such mistakes by documenting your function with JSDoc annotations:

/**
 * Add two numbers together
 * @param {number} x
 * @param {number} y
 * @return {number}
 */
function add(x, y) {
  return x + y;
}

The code itself hasn’t changed—you’ve only added a comment block using the @param and @return tags, each with a type in curly braces. TypeScript reads this comment during checking and treats your function as if it were written with TypeScript’s own type syntax:

/**
 * Add two numbers together
 */
function add(x: number, y: number): number {
  return x + y;
}

Calling the function with a string now produces a visible type error in editors like VS Code.

TypeScript evaluates that a call to add is incorrect if one of the arguments is a string.

Built-in Types and Expectations

JSDoc gives you access to the full set of TypeScript’s built-in types—number, string, object, Array, plus DOM-facing types like HTMLElement and MutationRecord. Beyond parameter and return types, you can also constrain structures like WeakMap instances:

/** @type {WeakMap<object>, string} */
const metadata = new WeakMap();


const object = {};
const otherObject = {};


metadata.set(object, 42);
metadata.set(otherObject, 'Hello world');

Attempting to store a non-string value in this mapping triggers an error:

Defining Custom Types

When built-in types aren’t enough, you can define your own shapes. In TypeScript, you’d write an interface:

interface Person {
  name: string;
  age: number;
  hobby?: string;
}

The equivalent JSDoc version uses the @typedef tag to name the type and @property (or the shorter @prop) to describe its fields. Note that optional properties are marked with a question mark before the colon:

/**
 * @typedef Person
 * @property {string} name - The person's name
 * @property {number} age - The person's age
 * @property {string} [hobby] - An optional hobby
 */

Once Person is defined, you can apply it to objects with @type. The compiler checks that objects of this type have the required properties:

Screenshot of an example of TypeScript throwing an error on our vanilla JavaScript object

Filling in the missing object properties resolves the error:

Our object now adheres to the Person interface defined above

Union and Literal Types

If a value should only be one of a few specific options, you can define a union type with literal string values:

/**
 * @typedef {'cat'|'dog'|'fish'} Pet
 */


/**
 * @typedef Person
 * @property {string} name - The person's name
 * @property {number} age - The person's age
 * @property {string} [hobby] - An optional hobby
 * @property {Pet} [pet] - The person's pet
 */

This creates a Pet type that accepts only 'cat', 'dog', or 'fish'. Assigning any other string, like 'kangaroo', results in a type error:

/** @type {Person} */
const caleb = {
  name: 'Caleb Williams',
  age: 33,
  hobby: 'Running',
  pet: 'kangaroo'
};
Screenshot of an an example illustrating that kangaroo is not an allowed pet type

Union types also work within larger type definitions. You can allow an object property to hold either one type or another:

/**
 * @typedef {'lizard'|'bird'|'spider'} ExoticPet
 */


/**
 * @typedef Person
 * @property {string} name - The person's name
 * @property {number} age - The person's age
 * @property {string} [hobby] - An optional hobby
 * @property {Pet|ExoticPet} [pet] - The person's pet
 */

This lets Person have either a Pet or an ExoticPet as a companion.

Generic Functions With @template

JSDoc annotations also support generic types—a way to keep type safety for functions that need to accept and return different types depending on their input. The canonical example is an identity function. In TypeScript syntax that looks like:

function identity<T>(target: T): T {
  return target;
}

Here T is a generic type: it links the return type to the argument’s type, whatever that happens to be. The same effect is achieved in JSDoc with the @template tag:

/**
 * @template T
 * @param {T} target
 * @return {T}
 */
function identity(target) {
  return x;
}

Type Casting

Sometimes the strict type checking is too strict for a particular situation. A common example occurs when handling DOM events. TypeScript correctly assumes that event.target is an EventTarget, but that type lacks properties you often need from actual element instances:

document.querySelector('input').addEventListener(event => {
  console.log(event.target.value);
};

Even though an element certainly has a value property, TypeScript raises an error because EventTarget doesn’t declare it:

A screenshot showing that value doesn’t exist on type EventTarget

To inform the compiler about the real type of the target, you can use a type cast. The syntax wraps the expression in parentheses and places the desired type before them:

document.getElementById('input').addEventListener('input', event => {
  console.log(/** @type {HTMLInputElement} */(event.target).value);
});
Screenshot of a valid example of type casting in VS Code.

This tells TypeScript to treat event.target as an HTMLInputElement when accessing its value property. If you ever need to completely bypass type checks for a specific object, you can annotate it as @type {any}—though that defeats the purpose of type checking and should be used sparingly.

Why JSDoc Instead of TypeScript Syntax?

Type annotation isn’t all-or-nothing. Projects that don’t want a compilation step or developers who prefer to stick strictly to ECMAScript standards can still take advantage of static type checking by using JSDoc comments. The source code remains pure JavaScript, and the type information stays close to the code as documentation. The TypeScript compiler doesn’t care whether the types come from TypeScript syntax or from comments—it evaluates both the same way.