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-disableneeds a reason on the same line. - Add
no-floating-promisesstyle 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);
}| Script | Command | Runs in CI |
|---|---|---|
dev | vite | No |
build | vite build | Yes |
lint | eslint . && prettier --check . | Yes |
typecheck | tsc --noEmit | Yes |
test | vitest --run | Yes |
ci | the three checks above in sequence | The only one that matters |
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 ciin CI, nevernpm 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?
eslint-config-prettier so no stylistic rule fights the formatter. Two tools, two jobs, one order.Do I need TypeScript to get types?
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.Related
Modules and project structure Numbers, dates and internationalisation
Last refreshed 2026-09-18.