Why code quality needs tools

JavaScript is forgiving. It accepts a missing semicolon, adds a number to a string without complaint, and creates a global variable when you mistype a name in older code. That makes it easy to start with, and easy to write code that runs but is wrong. Many of these mistakes only show up for a particular input, long after the code was written.

Four kinds of tool catch them early, each a different class of problem. They are the JavaScript versions of the tools described for Python in code style and good habits.

Four kinds of code quality tool, from fast and automatic to slower and written by you. A formatter, such as Prettier or Biome, fixes inconsistent layout. A linter, such as ESLint or Biome, finds likely bugs and risky patterns. A type checker, such as tsc with TypeScript or @ts-check, finds wrong types and missing properties. Tests, with node:test or Vitest, find wrong results and regressions. Formatter Prettier, Biome inconsistent layout Linter ESLint, Biome likely bugs, risky patterns Type checker tsc, @ts-check wrong types, missing properties Tests node:test, Vitest wrong results, regressions
Each tool catches a different kind of problem. From left to right they get slower and need more of your own work, but find deeper mistakes; none of them replaces the others.

Style conventions

JavaScript has no single official style guide like Python's PEP 8, but most projects agree on the same basics:

  • camelCase for variables and functions: totalKwh, parseReading().
  • PascalCase for classes and constructors: MeterReading.
  • UPPER_SNAKE_CASE for fixed configuration values: MAX_RETRIES, VAT_RATE.
  • const by default, let only when the variable is reassigned, and no var in new code (see variables, values and types).
  • === and !==, never the loose == (see operators, comparison and type coercion).
  • Two spaces of indentation is the most common choice, and the default of most tools.

Published guides, such as the Airbnb and Google JavaScript style guides, write these down in detail. In practice, the configuration of your formatter and linter is the style guide.

Semicolons and automatic semicolon insertion

JavaScript lets you leave out most semicolons: the parser inserts them at line ends where the code would otherwise be invalid. This automatic semicolon insertion (ASI) has a trap. After return, a line break always ends the statement:

function monthlySummary(kwh) {
  return
    {
      total: kwh
    };
}

console.log(monthlySummary(312.5));
undefined

The function returns undefined, and the object below is never reached. Running the code through a formatter shows what the parser saw:

function monthlySummary(kwh) {
  return;
  {
    total: kwh;
  }
}

Whether a project writes semicolons or not is a matter of taste; both styles are common. What matters is that a formatter applies the choice consistently, and that the value of a return starts on the same line.

Strict mode as a safety net

Strict mode turns some silent mistakes into errors. Without it, assigning to a misspelled name creates a new global variable and the program runs on with a wrong result; with it, the typo stops the program on the spot. ES modules and classes are always strict.

"use strict";

let total = 0;
for (const kwh of [12.5, 9.75]) {
  totl = total + kwh;
}
console.log(total);
ReferenceError: totl is not defined

Without the first line, the same code prints 0 and leaves a stray global totl behind.

Formatting with Prettier

A code formatter rewrites source code into a fixed layout (indentation, line breaks, spacing, quotes) without changing what the code does. The most widely used one for JavaScript is Prettier (version 3.x as of 2026). It deliberately has few options: the point is to stop discussing layout, not to tune it.

Before:

const readings=[{meter:'KE-1042',kwh:12.5},{meter:'KE-1043',kwh:9.75}]
function totalKwh(list){return list.reduce((sum,r)=>sum+r.kwh,0)}
console.log( totalKwh(readings) )

After npx prettier --write report.js:

const readings = [
  { meter: "KE-1042", kwh: 12.5 },
  { meter: "KE-1043", kwh: 9.75 },
];
function totalKwh(list) {
  return list.reduce((sum, r) => sum + r.kwh, 0);
}
console.log(totalKwh(readings));

Prettier chose double quotes, added semicolons and a trailing comma, and split the array because it did not fit within its default line width of 80 characters. The few settings it has go in a .prettierrc file in the project root:

{
  "semi": true,
  "singleQuote": false,
  "printWidth": 100
}
  • Let the editor format on save (VS Code: the Prettier extension and the setting "Format On Save"), so files are always formatted.
  • npx prettier --check . only reports unformatted files and exits with an error, which is what a CI pipeline runs.
  • Format a whole project once, in a separate commit, so that later diffs show real changes instead of reshuffled spaces.

Linting with ESLint

A linter analyzes code without running it. ESLint is the standard linter for JavaScript: an open-source tool whose checks are individual, configurable rules, such as no-unused-vars or eqeqeq, and which plugins extend for frameworks and TypeScript. Unlike a formatter, it looks for code that is probably wrong:

function totalKwh(list) {
  let count = 0;
  let total = 0;
  for (const r of list) {
    if (r.status == "ok") total += r.kwh;
  }
  consol.log("Total:", total);
  return total;
}

module.exports = { totalKwh };
npx eslint report.js
/home/sam/kestrel/report.js
  2:7   error  'count' is assigned a value but never used  no-unused-vars
  5:18  error  Expected '===' and instead saw '=='           eqeqeq
  7:3   error  'consol' is not defined                     no-undef

✖ 3 problems (3 errors, 0 warnings)

Each line gives the position, the severity, the message and the rule. The misspelled consol would only have failed when the function ran; ESLint found it by reading the file.

Configuration

ESLint is installed per project, as a development dependency (see modules and npm). Its settings live in eslint.config.js (or .mjs), the so-called flat config: an array of configuration objects that apply in order.

npm install --save-dev eslint @eslint/js globals
// eslint.config.mjs
import js from "@eslint/js";
import globals from "globals";

export default [
  js.configs.recommended,
  {
    files: ["**/*.js"],
    languageOptions: {
      sourceType: "commonjs",
      globals: globals.node,
    },
    rules: {
      eqeqeq: "error",
      "no-console": "off",
    },
  },
];
  • js.configs.recommended switches on the rules the ESLint team considers almost always right, such as no-undef, no-unused-vars, no-unreachable and no-debugger.
  • globals.node tells ESLint which global names exist in Node.js (process, require, module); use globals.browser for code that runs in a web page.
  • Each rule is set to "off", "warn" or "error". Errors make eslint exit with a failure code, which fails a CI pipeline.
  • npx eslint --fix . repairs what can be repaired automatically; the rest needs you.

Testing

Linters and type checkers read code; tests run it and check the results. A unit test calls one function with known input and compares the result with the expected value. Once written, it runs in a second, forever: a bug that a test covers cannot quietly come back.

Assertions

An assertion is a check in code that a condition holds: if it does, nothing happens; if not, it throws an error that describes the difference. Node.js includes the module node:assert; use its strict variant, node:assert/strict, which compares primitive values strictly, with Object.is() (practically ===), and objects by reference. This example tests a parser for meter readings, and the last assertion finds a real bug:

const assert = require("node:assert/strict");

function parseReading(line) {
  const [meter, kwh] = line.split(",");
  return { meter, kwh: Number(kwh) };
}

const reading = parseReading("KE-1042,12.5");
assert.equal(reading.kwh, 12.5);
assert.deepEqual(reading, { meter: "KE-1042", kwh: 12.5 });
console.log("2 assertions passed");

console.log(parseReading("KE-1043,"));
assert.throws(() => parseReading("KE-1043,"), RangeError);
2 assertions passed
{ meter: 'KE-1043', kwh: 0 }
AssertionError [ERR_ASSERTION]: Missing expected exception (RangeError).

A missing reading silently became 0 kWh, because Number("") is 0. For an energy bill that is a serious error, and the test exposed it. The fixed version rejects empty and non-numeric values:

const assert = require("node:assert/strict");

function parseReading(line) {
  const [meter, text] = line.split(",");
  const kwh = Number(text);
  if (text === undefined || text.trim() === "" || Number.isNaN(kwh)) {
    throw new RangeError(`No valid reading for ${meter}`);
  }
  return { meter, kwh };
}

assert.deepEqual(parseReading("KE-1042,12.5"), { meter: "KE-1042", kwh: 12.5 });
assert.throws(() => parseReading("KE-1043,"), RangeError);
assert.throws(() => parseReading("KE-1044,n/a"), RangeError);
assert.notEqual({ kwh: 12.5 }, { kwh: 12.5 });
console.log("all assertions passed");
all assertions passed

Note the last assertion: two objects with the same contents are still two different objects, so equal (strict comparison, by reference for objects) sees them as unequal. To compare contents, use deepEqual.

Node's built-in test runner

A test runner is a tool that finds test files, runs every test in them, keeps going when one fails, and reports which passed and which failed. Node.js has had a stable one, node:test, since version 20. A test file calls test() with a name and a function:

// tariff.test.js
const test = require("node:test");
const assert = require("node:assert/strict");
const { energyCost } = require("./tariff.js");

test("multiplies kWh by the price", () => {
  assert.equal(energyCost(12.5, 0.27), 3.38);
});

test("zero usage costs nothing", () => {
  assert.equal(energyCost(0, 0.27), 0);
});

test("rejects negative readings", () => {
  assert.throws(() => energyCost(-5, 0.27), RangeError);
});

node --test finds files whose names end in .test.js (and similar patterns) and runs them:

node --test
✔ multiplies kWh by the price (1.644348ms)
✔ zero usage costs nothing (0.19752ms)
✔ rejects negative readings (0.624617ms)
ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 69.31789

A failing test shows the assertion's message, here 3.38 !== 3.37, and the line of the failing assertion, and the command exits with an error code. describe() groups related tests, and node --test --watch reruns them whenever a file changes. Popular third-party runners are Vitest and Jest; they add features such as snapshot testing, but the ideas are the same.

What to test

  • The normal case: a typical input and its expected result.
  • Edge cases: zero, empty strings and arrays, missing values, the largest and smallest allowed values. Most bugs live here, as the empty reading above shows. (Choosing them systematically: boundary value analysis.)
  • Errors: invalid input should be rejected in the way you designed, with assert.throws().
  • Every bug you fix: first write a test that reproduces it, then fix the code (see a calm method for hard bugs).

Pure functions (same input, same output, no side effects) are the easiest to test, which is one more reason to keep calculations apart from screen output and network calls. Writing the test before the code is called test-driven development.

Adding types

JavaScript uses dynamic typing: a variable can hold any type, and type mistakes only show up when that line runs, sometimes not as an error but as a wrong result:

function addReading(total, kwh) {
  return total + kwh;
}

console.log(addReading(10, 12.5));
console.log(addReading(10, "12.5"));
22.5
1012.5

A type checker reads the code, knows which types each function expects, and reports a mismatch such as the string above before the code ever runs. There are two ways to give it that information: JSDoc comments in plain JavaScript, or TypeScript.

JSDoc with @ts-check

JSDoc is a convention for documentation comments in JavaScript: a comment that starts with /** and uses tags such as @param, @returns and @typedef to describe a function's parameters, result and types. Editors show it as help text. With // @ts-check at the top of the file, the TypeScript checker built into editors such as VS Code also checks the code against those comments. The file stays ordinary JavaScript that runs as it is:

// @ts-check

/**
 * @typedef {object} Reading
 * @property {string} meter
 * @property {number} kwh
 */

/**
 * Adds up the kWh of all readings.
 * @param {Reading[]} readings
 * @returns {number}
 */
function totalKwh(readings) {
  return readings.reduce((sum, r) => sum + r.kwh, 0);
}

console.log(totalKwh([
  { meter: "KE-1042", kwh: 12.5 },
  { meter: "KE-1043", kwh: 9.75 },
]));
22.25

Pass kwh: "12.5" instead, and the editor underlines it with "Type 'string' is not assignable to type 'number'". This route needs no build step, which makes it a good fit for small projects and scripts.

TypeScript

TypeScript is a language by Microsoft that extends JavaScript with static types. Every JavaScript program is (nearly) valid TypeScript; TypeScript adds a type annotation syntax, a colon followed by a type after a variable, parameter or function head, plus interfaces and type aliases. The compiler, tsc, checks the types and produces plain JavaScript, because browsers and Node.js run JavaScript, not the type annotations.

interface Reading {
  meter: string;
  kwh: number;
}

function totalKwh(readings: Reading[]): number {
  return readings.reduce((sum, r) => sum + r.kwh, 0);
}

totalKwh([{ meter: "KE-1042", kwh: "12.5" }]);
npx tsc --noEmit
meter.ts(10,31): error TS2322: Type 'string' is not assignable to type 'number'.

Project settings live in tsconfig.json. "strict": true switches on the strict checks, which is what new projects should use; allowJs and checkJs let the same checker cover JavaScript files with JSDoc:

{
  "compilerOptions": {
    "strict": true,
    "allowJs": true,
    "checkJs": true,
    "noEmit": true
  }
}

TypeScript is very widely used: by GitHub's Octoverse 2025 count of monthly contributors it became the most used language on GitHub in August 2025. Its compiler has been rewritten in Go: TypeScript 7.0 (July 2026) is the native version with much faster builds, and 6.0 was the last version written in TypeScript itself.

Running TypeScript directly

Recent Node.js versions can run a .ts file directly (node meter.ts): they strip the type annotations and run what remains. This is on by default since Node.js 22.18 and 23.6 and stable since 24.12 and 25.2, so it works on the current LTS lines as of 2026 (it does not on Node 20). Two limits:

  • Stripping is not checking. Node.js runs meter.ts above without complaint; only tsc or your editor reports the type error. Keep tsc --noEmit in your checks.
  • Only syntax that can simply be erased is supported. Features that generate code, such as enum and runtime namespaces, need the full compiler.

Putting it together

Tools only help when they run every time, without anyone having to remember. A common setup collects the commands as scripts in package.json, so every developer and the pipeline run exactly the same checks:

{
  "name": "meter-report",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "format": "prettier --write .",
    "lint": "eslint .",
    "typecheck": "tsc --noEmit",
    "test": "node --test"
  }
}

npm run lint, npm run typecheck and npm test then work in every clone of the project. Run the checks at three moments:

  1. In the editor: format on save, lint and type errors underlined while you type. The fastest feedback there is.
  2. Before a commit: a Git pre-commit hook can run the formatter and linter on the changed files.
  3. In the CI pipeline: every push runs prettier --check, the linter, the type checker and the tests, and a failure blocks the merge (see testing and quality in pipelines).

None of this replaces a human code review, but it frees the reviewer to look at what the code does instead of where the spaces are.