Skip to main content

Command Palette

Search for a command to run...

JSDoc: The TypeScript Experience Without TypeScript

Published
9 min read
JSDoc: The TypeScript Experience Without TypeScript

Remember that time you called a function, forgot what parameters it needed, then had to scroll up 200 lines to check? Or when you refactored a function and broke 12 files because nothing told you where it was being used incorrectly?

JSDoc fixes this. It gives you TypeScript-level autocomplete, type checking, and documentation — all in plain JavaScript. No build step, no configuration, just comments.

The Problem with Undocumented Code

Here's a function without documentation:

function calculatePrice(item, quantity, discount) {
  const subtotal = item.price * quantity;
  const discountAmount = subtotal * (discount / 100);
  return subtotal - discountAmount;
}

When you call this function three months later, you have questions: Is discount a percentage (15) or a decimal (0.15)? Is item an object or just the price? What shape does it have?

Here's the same function with JSDoc:

/**
 * Calculate final price after discount
 * @param {Object} item - The product item
 * @param {number} item.price - Unit price
 * @param {string} item.name - Product name
 * @param {number} quantity - Number of items
 * @param {number} discount - Discount percentage (0-100)
 * @returns {number} Final price after discount
 */
function calculatePrice(item, quantity, discount) {
  const subtotal = item.price * quantity;
  const discountAmount = subtotal * (discount / 100);
  return subtotal - discountAmount;
}

Now when you type calculatePrice(, your editor shows you exactly what each parameter needs. No guessing. No context switching.

Custom Types with @typedef

For objects you use frequently, define them once and reuse everywhere:

/**
 * @typedef {Object} User
 * @property {string} id - User ID
 * @property {string} email - User email
 * @property {string} name - User name
 * @property {string[]} roles - User roles
 * @property {Date} createdAt - Account creation date
 */

/**
 * @typedef {Object} AuthToken
 * @property {string} token - JWT token
 * @property {Date} expiresAt - Token expiration
 */

/**
 * Authenticate user and return token
 * @param {string} email - User's email
 * @param {string} password - User's password
 * @returns {Promise<{user: User, auth: AuthToken}>}
 */
async function login(email, password) {
  const user = await User.findOne({ email });
  if (!user) throw new Error('User not found');
  
  const valid = await bcrypt.compare(password, user.password);
  if (!valid) throw new Error('Invalid password');
  
  const token = jwt.sign({ userId: user.id }, SECRET);
  
  return {
    user: {
      id: user.id,
      email: user.email,
      name: user.name,
      roles: user.roles,
      createdAt: user.createdAt
    },
    auth: {
      token,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
    }
  };
}

When you call login(), your editor knows the returned object has user and auth properties with their full structure. Type user. and see all five properties. Type auth. and see token and expiration.

Importing Types from Libraries

You don't have to define everything yourself. Import types from npm packages:

/**
 * @typedef {import('express').Request} Request
 * @typedef {import('express').Response} Response
 * @typedef {import('express').NextFunction} NextFunction
 */

/**
 * Get user by ID
 * @param {Request} req - Express request
 * @param {Response} res - Express response
 * @returns {Promise<void>}
 */
async function getUser(req, res) {
  const userId = req.params.id;
  const user = await User.findById(userId);
  
  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }
  
  res.json(user);
}

Type req. and your editor suggests params, query, body, headers — everything Express provides. Same with res.status(), res.json(), res.send().

Type Checking with @ts-check

Add one comment at the top of your file and get TypeScript error checking:

// @ts-check

/**
 * @param {number} a
 * @param {number} b
 * @returns {number}
 */
function add(a, b) {
  return a + b;
}

const result = add(5, '10'); // ❌ Error: Argument of type 'string' is not assignable to parameter of type 'number'

Your editor warns you about type mismatches before you run the code. No TypeScript compiler needed.

This catches bugs at write-time:

// @ts-check

/**
 * @typedef {Object} Product
 * @property {string} id
 * @property {string} name
 * @property {number} price
 */

/**
 * @param {Product[]} products
 * @returns {number}
 */
function calculateTotal(products) {
  return products.reduce((sum, product) => sum + product.price, 0);
}

const products = [
  { id: '1', name: 'Book', price: 20 },
  { id: '2', name: 'Pen', price: 5 },
  { id: '3', name: 'Notebook', price: '15' } // ❌ Error: Type 'string' is not assignable to type 'number'
];

calculateTotal(products);

The error shows up immediately in your editor. Not in production. Not in testing. Right here.

Enums and Literal Types

Document allowed values to prevent invalid inputs:

/**
 * @typedef {'pending' | 'active' | 'completed' | 'cancelled'} OrderStatus
 */

/**
 * Update order status
 * @param {string} orderId - Order ID
 * @param {OrderStatus} status - New status
 * @returns {Promise<void>}
 */
async function updateOrderStatus(orderId, status) {
  await Order.updateOne(
    { _id: orderId },
    { status }
  );
}

// Editor suggests: 'pending', 'active', 'completed', 'cancelled'
await updateOrderStatus('order-123', 'active');

// Editor warns about invalid value
await updateOrderStatus('order-123', 'shipped'); // ❌ Not a valid OrderStatus

Class Documentation

JSDoc works great with ES6 classes:

/**
 * User service for managing user accounts
 */
class UserService {
  /**
   * @param {Object} database - Database connection
   */
  constructor(database) {
    this.db = database;
  }

  /**
   * Find user by email
   * @param {string} email - User email address
   * @returns {Promise<User|null>} User object or null
   */
  async findByEmail(email) {
    return await this.db.users.findOne({ email });
  }

  /**
   * Create new user
   * @param {Object} userData - User data
   * @param {string} userData.email - Email address
   * @param {string} userData.password - Password
   * @param {string} userData.name - Full name
   * @returns {Promise<User>} Created user
   */
  async create(userData) {
    const hashedPassword = await this.hashPassword(userData.password);
    return await this.db.users.create({
      ...userData,
      password: hashedPassword
    });
  }

  /**
   * @private
   * @param {string} password
   * @returns {Promise<string>}
   */
  async hashPassword(password) {
    return await bcrypt.hash(password, 10);
  }
}

const userService = new UserService(db);
const user = await userService.findByEmail('[email protected]');

The @private tag tells your editor that hashPassword is internal. It might not show in autocomplete or might show with a warning.

Callbacks and Higher-Order Functions

Document function parameters that are themselves functions:

/**
 * @callback ValidatorFn
 * @param {any} value - Value to validate
 * @returns {boolean} True if valid
 */

/**
 * @callback TransformFn
 * @param {any} value - Value to transform
 * @returns {any} Transformed value
 */

/**
 * Process array with validation and transformation
 * @param {any[]} items - Array to process
 * @param {ValidatorFn} validate - Validation function
 * @param {TransformFn} transform - Transform function
 * @returns {any[]} Processed array
 */
function processArray(items, validate, transform) {
  return items
    .filter(item => validate(item))
    .map(item => transform(item));
}

const numbers = [1, 2, 3, 4, 5];
const result = processArray(
  numbers,
  (num) => num > 2,     // Editor knows this should return boolean
  (num) => num * 2      // Editor knows this can return any type
);

Real-World Express Example

Here's how JSDoc makes Express development better:

// @ts-check
/**
 * @typedef {import('express').Request} Request
 * @typedef {import('express').Response} Response
 * @typedef {import('express').NextFunction} NextFunction
 */

/**
 * @typedef {Object} AuthUser
 * @property {string} id - User ID
 * @property {string[]} roles - User roles
 */

/**
 * @typedef {Request & {user: AuthUser}} AuthRequest
 */

/**
 * Authenticate request using JWT
 * @param {Request} req
 * @param {Response} res
 * @param {NextFunction} next
 */
function authenticate(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  
  if (!token) {
    return res.status(401).json({ error: 'No token provided' });
  }

  try {
    const decoded = jwt.verify(token, SECRET);
    req.user = decoded;
    next();
  } catch (error) {
    res.status(401).json({ error: 'Invalid token' });
  }
}

/**
 * Get current user profile
 * @param {AuthRequest} req
 * @param {Response} res
 */
async function getProfile(req, res) {
  const user = await User.findById(req.user.id);
  res.json(user);
}

When you type req.user., your editor suggests id and roles. When you type res., it suggests all Express response methods.

Common JSDoc Tags Reference

Here are the tags you'll use most:

/**
 * @param {Type} name - Description
 * @returns {Type} Description
 * @throws {Error} Description
 * @typedef {Object} TypeName
 * @property {Type} name - Description
 * @callback CallbackName
 * @template T - Generic type parameter
 * @example
 * functionName(arg1, arg2)
 * @deprecated Use newFunction() instead
 * @see {@link OtherFunction}
 * @private
 * @async
 */

When Not to Use JSDoc

JSDoc isn't always the answer:

Don't use JSDoc when:

  • Your team is already using TypeScript (just use TypeScript)

  • The function is so simple that docs add no value

  • You're documenting implementation details instead of the API

Do use JSDoc when:

  • You're in a JavaScript codebase and want better developer experience

  • You need type safety but can't add a build step

  • You're maintaining a library and want to help users

  • You want autocomplete without migrating to TypeScript

Making It a Habit

The best way to start:

  1. Add // @ts-check to the top of new files

  2. Document function signatures as you write them

  3. Define @typedef for objects you use frequently

  4. Import types from @types packages for libraries you use

Type /** above any function and hit Enter. Most editors auto-generate a JSDoc template. Fill in the types. Done.

Five extra seconds per function. Saves hours of debugging later.

The Documentation Bonus

JSDoc comments can generate actual documentation sites. Tools like jsdoc or documentation.js turn your comments into HTML docs:

npm install -g jsdoc
jsdoc src/**/*.js -d docs

Your inline comments become browsable documentation. One source of truth for both developers and docs.


TLDR;

JSDoc gives you TypeScript-level autocomplete, type checking, and documentation in plain JavaScript. Add /** */ comments above functions with @param, @returns, and @typedef tags. Your editor parses these and provides autocomplete, parameter hints, and error checking. Use // @ts-check at the top of files to enable type validation. Import types from npm packages with @typedef {import('package').Type}. No build step, no configuration — just better developer experience. Five seconds to document a function, hours saved in debugging and onboarding.