Tooling: linting, formatting, bundling and testing

ESLint and Prettier, a Vite dev and build workflow, type checking JavaScript with JSDoc, Vitest basics and wiring it all into npm scripts and CI.

Linting and formatting

Prettier decides layout — indentation, quotes, line breaks — and never argues about meaning. ESLint finds likely bugs and enforces project rules. Run Prettier first and keep the two from fighting by extending the config that turns off conflicting style rules.

// eslint.config.js (flat config)
import js from '@eslint/js';
import prettier from 'eslint-config-prettier';

export default [
  js.configs.recommended,
  {
    files: ['**/*.js'],
    languageOptions: {
      ecmaVersion: 2024,
      sourceType: 'module',
      globals: { window: 'readonly', document: 'readonly' },
    },
    rules: {
      'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
      'no-console': ['warn', { allow: ['warn', 'error'] }],
      eqeqeq: ['error', 'smart'],
      'prefer-const': 'error',
    },
  },
  prettier,                                  // keep this last
];
npx eslint . --fix        # report and fix what is safe
npx prettier --write .    # format every supported file
npx prettier --check .    # CI: fail on unformatted files
npx tsc --noEmit          # type-check without emitting files
  • Fix rules rather than disabling them inline; every eslint-disable needs a reason on the same line.
  • Add no-floating-promises style rules through the typed preset if you use promises heavily — an unawaited promise is the most common silent bug in a codebase.
  • Format on save in the editor so formatting never appears in a review diff.
  • Keep generated folders out of both tools with an ignore file; linting dist/ wastes time and reports meaningless errors.

Dev server, build and type checking

A modern bundler gives you a fast dev server with hot reloading, then a production build with minification and code splitting. Vite is the common default because it needs almost no configuration for a plain JavaScript or TypeScript project.

// vite.config.js
import { defineConfig } from 'vite';

export default defineConfig({
  server: { port: 5173, proxy: { '/api': 'http://localhost:8000' } },
  build: { outDir: 'dist', sourcemap: true, target: 'es2022' },
  define: { __APP_VERSION__: JSON.stringify(process.env.npm_package_version) },
});
// types checked from JSDoc - no build step required
/**
 * @typedef {Object} Order
 * @property {string} id
 * @property {number} total      in minor units
 * @property {string[]} [tags]
 */

/**
 * @param {Order[]} orders
 * @param {{ includeVoid?: boolean }} [options]
 * @returns {number}
 */
export function revenue(orders, options = {}) {
  const { includeVoid = false } = options;
  return orders
    .filter(o => includeVoid || !o.tags?.includes('void'))
    .reduce((sum, o) => sum + o.total, 0);
}
ScriptCommandRuns in CI
devviteNo
buildvite buildYes
linteslint . && prettier --check .Yes
typechecktsc --noEmitYes
testvitest --runYes
cithe three checks above in sequenceThe only one that matters
⚠️
Set checkJs in tsconfig.json and add a // @ts-check comment at the top of a file to type-check it incrementally. Turning it on across an existing codebase at once produces hundreds of errors and the team disables the whole thing within a week.

Tests and continuous integration

Vitest uses the same transform pipeline as your dev server, so a test file can import the same modules the app does with no extra configuration, and it runs in watch mode locally and once in CI.

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { revenue } from '../src/lib/revenue.js';

describe('revenue', () => {
  it('sums the totals', () => {
    expect(revenue([{ id: 'a', total: 250 }, { id: 'b', total: 100 }])).toBe(350);
  });

  it('skips voided orders unless asked', () => {
    const orders = [{ id: 'a', total: 250, tags: ['void'] }];
    expect(revenue(orders)).toBe(0);
    expect(revenue(orders, { includeVoid: true })).toBe(250);
  });

  it('reports a failure when the fetch is not ok', async () => {
    const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 500 });
    vi.stubGlobal('fetch', fetchMock);
    await expect(load('1')).rejects.toThrow('HTTP 500');
    expect(fetchMock).toHaveBeenCalledOnce();
    vi.unstubAllGlobals();
  });

  beforeEach(() => localStorage.clear());
});
# .github/workflows/ci.yml
on: [push, pull_request]
jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck
      - run: npm run test -- --run
      - run: npm run build
  • Use npm ci in CI, never npm install — it installs exactly the lockfile and fails if the two disagree.
  • Test behaviour through the public interface; a test that mocks everything only proves the mocks work.
  • Keep the pipeline fast: run lint and type-check before the test suite so a typo fails in seconds rather than minutes.
  • Never let a failing check be merged; a red pipeline that everyone ignores is worse than no pipeline.

FAQ

ESLint or Prettier first?
Format with Prettier, then lint with ESLint, and extend eslint-config-prettier so no stylistic rule fights the formatter. Two tools, two jobs, one order.
Do I need TypeScript to get types?
No. JSDoc annotations plus checkJs give real checking in plain JavaScript files, which is often enough for a small project. Move to TypeScript when you want the types to be part of the syntax and tooling everywhere.

Modules and project structure Numbers, dates and internationalisation

Last refreshed 2026-09-18.