Code Quality: Style, Linting, Testing and TypeScript
Keeping JavaScript maintainable: consistent style with a formatter, catching mistakes with ESLint, writing tests with Node's built-in test runner, and adding types with JSDoc or TypeScript.
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.
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. -
constby default,letonly when the variable is reassigned, and novarin 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.recommendedswitches on the rules the ESLint team considers almost always right, such asno-undef,no-unused-vars,no-unreachableandno-debugger. -
globals.nodetells ESLint which global names exist in Node.js (process,require,module); useglobals.browserfor code that runs in a web page. -
Each rule is set to
"off","warn"or"error". Errors makeeslintexit 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.tsabove without complaint; onlytscor your editor reports the type error. Keeptsc --noEmitin your checks. -
Only syntax that can simply be erased is supported. Features that
generate code, such as
enumand 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:
- In the editor: format on save, lint and type errors underlined while you type. The fastest feedback there is.
- Before a commit: a Git pre-commit hook can run the formatter and linter on the changed files.
-
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.