Tooling

Understand what the tools in a project actually do, so you can choose to skip them.

32of 32
≈ 10Min read
9Parts

You may not need any of it

A page with <script type="module"> and a few files works today, in every browser, with no install step. Everything below exists to solve a problem — and it is worth knowing which problem, so you can tell whether you have it.

  • A bundler — because hundreds of separate module requests are slow, and because import "zod" is not a URL.
  • A transpiler — because you want to write syntax older browsers do not have.
  • A package manager — because you are using code other people wrote.
  • A linter and a formatter — because consistency and small mistakes.
  • TypeScript — because a large codebase changes more safely with types.

npm

  • "type": "module" makes .js files ESM. Without it Node treats them as CommonJS.
  • ^3.23.0 allows 3.x but not 4.x. ~3.23.0 allows 3.23.x only. An exact version has no prefix.
  • package-lock.json records the exact tree that was installed. Commit it. It is what makes a build reproducible.
  • dependencies ship; devDependencies do not.
the commands you will use
npm init -y                 // create package.json
npm install vite -D         // a dev dependency
npm install zod             // a runtime dependency
npm ci                      // install exactly what the lockfile says — for CI
npm run dev                 // run a script
npm outdated                // what has newer versions
npm audit                   // known vulnerabilities
package.json
{
  "name": "my-site",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "lint": "eslint ."
  },
  "dependencies": { "zod": "^3.23.0" },
  "devDependencies": { "vite": "^5.0.0" }
}

What a bundler does

It follows your imports from an entry file, produces one or a few files, and along the way: resolves bare specifiers like "zod" to real paths, splits code so a route's JavaScript loads only when needed, removes unused exports, and hashes filenames so they can be cached forever.

vite.config.js — usually this short
import { defineConfig } from "vite";

export default defineConfig({
  build: { outDir: "dist", sourcemap: true },
  server: { port: 5173 },
});

In development Vite does not bundle: it serves your modules natively and only transforms what it must, which is why the dev server starts instantly however large the project is.

Source maps

A source map maps the built file back to your source. Generate them, and upload them to your error reporting service — but consider not serving them publicly if the source is private.

without them a stack trace is useless
at n (index-a3f2.js:1:24815)        // the bundle
at readForm (form.js:12:22)         // with a source map

Linting and formatting

  • A formatter — Prettier, or Biome — decides quotes, semicolons and line breaks. It has no opinions worth arguing with. Run it on save.
  • A linter — ESLint, or Biome — catches real mistakes: an unused variable, a missing await, ==, a React hook in a condition. Worth configuring.
  • Do not let the linter enforce formatting; the two fight. Let each do its job.
the rules that earn their place
"no-unused-vars"
"eqeqeq"
"no-implicit-globals"
"require-await"
"no-floating-promises"     // a promise nobody handles

TypeScript, briefly

  • It catches a whole class of mistakes before the code runs, and it makes refactoring far safer.
  • The types are erased at build time: nothing checks them at runtime. Data from a network or a form still has to be validated.
  • It costs a build step and some ceremony. For a small page it is not worth it; past a few thousand lines with more than one person, it usually is.
  • A middle path: plain JavaScript with JSDoc types, checked by tsc --checkJs. No build step, most of the benefit.
what it buys and what it costs
type Door = { id: number; name: string; days: number; archived?: boolean };

function priceOf(door: Door, rate = 18): number {
  return Math.round(door.days * 100 * (1 + rate / 100));
}

priceOf({ id: 1, name: "A" });   // error: 'days' is missing

Testing

Test the pure functions first — they are the cheapest to test and the ones a bug hides in longest. For the DOM, test what a person would do: find by role and text, click, assert what appears.

the built-in runner, no dependencies
// sum.test.js  →  node --test
import { test } from "node:test";
import assert from "node:assert/strict";
import { priceOf } from "./price.js";

test("adds VAT", () => {
  assert.equal(priceOf({ days: 5 }, 18), 590);
});

test("rejects a bad value", () => {
  assert.throws(() => priceOf({ days: -1 }));
});

Deploying a static site

  • npm run build produces the files. Nothing else needs to run on the server.
  • Serve dist/ from any static host. Long cache headers on hashed filenames; no cache on index.html.
  • Check the output: what is the bundle size, and what is in it? One accidental import of a date library is 200 KB.
  • Test the built version, not only the dev server. They differ.

The order to add things

Start with plain files. Add a formatter the first time you argue about style. Add npm and a bundler the first time you want a package. Add a linter when more than one person is committing. Add types when you are afraid to change something.

Adding them in that order means every tool in the project is there because of a problem you actually had.

Practice