Quick and dirty linting

February 13th, 2026. Tagged: JavaScript, Music


This post is another installment of the series dedicated to building sightread.org. Parts 1, 2, 3, 4, 5, 6.

I've been using TypeScript (actually JSDoc + tsc --noEmit) as my linter for a while. Well, TBH, I didn't do it intentionally, it's just something Claude does outta the box. I saw some linting going on and left it at that. But then I wanted to add a custom lint: hoist and alphabetize all type imports at the top of every JavaScript module. So I looked into how my linting works.

The default typescript-based lint catches type errors, unused variables and, I guess, more. But it doesn't catch style issues: let where const would do, string concatenation instead of template literals, forgotten parseInt radix, and so on. It was time to up my linting game.

I could add ESLint. But I kinda like the new family of fast Rust-based dev tools. So...

Enter Biome

Biome is a Rust-based linter that's reportedly 10-100x faster than ESLint. And it works with npx:

npx @biomejs/biome check src test --linter-enabled=true --write

The --write flag is there to auto-fix what it can (e.g. let to const).

And that's it. No config file, no package.json... or so I hoped. Because, you know, I didn't want to tweak any linting rules. I sign up to the philosophy that any convention is better than no convention, personal preference be damned.

However in this all-jsdoc-typed project, no transpilation and no type-casting, I do type the "business" bits and leave the DOM to me (see the post about typing). Which leads to any. So I needed one tiny config to disable a single rule, because this is not possible on the command line with Biome (booo!). Here goes:

{
  "linter": {
    "rules": {
      "suspicious": {
        "noExplicitAny": "off"
      }
    }
  }
}

Ah, I also use any in my .d.ts type definitions for the one third-party library ABCJS I use for notation.

Custom rules

As I mentioned I have a project convention that Biome doesn't know about: all @typedef imports must be hoisted to the top of the file and sorted alphabetically. Like this:

/**
 * @typedef {import('./types').Pattern} Pattern
 * @typedef {import('./types').TimeSignature} TimeSignature
 */

import { hello } from './greet.js';
// ... rest of the code

The reason is I want to look at as little type info as possible mixed up with the code. Unless, that is, I do want to look at types speceifically. (Which I do, it's often helpful when thinking big picture thoughts about what kind of objects are passed around in the app. Anyway.)

So my lint script runs Biome first, then does a second pass with a custom check that does simple string matching without involving any AST. It seems to work for me. This check is in my lint.js runner. I'm probably negating some of the Rust perf benefits by having a custom js-based lint, but oh well.

Build integration

The lint runs as part of the build too, if linting fails, the build is abandoned (with a joyful abandon).

All in all

The whole linting setup is just one lint.js script and one 6-line JSON config file (sigh). That includes all Biome defaults (except one) and one custom lint.

Comments? Find me on BlueSky, Mastodon, LinkedIn, Threads, Twitter