Code formatting and linting in 2026: deno fmt, deno lint, a CI gate, and AI agents held to it
My formatting and linting setup: deno fmt and deno lint, a CI step that blocks the merge, AI agents held to the same check, and what to pick on Node or Bun.
Anton Shubin8 min read
The tool: dotfilesCode: github.com/spy4x/dotfiles (opens in a new tab)
Contents
TL;DR
- On Deno, deno fmt and deno lint replace Prettier and ESLint: no packages, a few lines in deno.json.
- deno fmt reads .editorconfig for indentation, line width and line endings; deno.json wins, and quote_type is ignored.
- The check counts only when CI runs it and a red run blocks the merge, for people and AI agents alike.
- On Node or Bun: Biome for one tool that formats and lints, dprint for many languages, or keep a working Prettier setup.
In 2022 I wrote about
code formatting with Prettier:
Husky, lint-staged and a .prettierrc. That post is archived now, because my
TypeScript projects run on Deno, and Deno ships a formatter and a linter in the
same binary.
This post is the setup I use in my TypeScript repositories today. It's for developers and tech leads who want formatting and linting that nobody has to think about, and for teams where AI agents write part of the code. If you're on Node or Bun, the last section compares Prettier, Biome and dprint as they are this month.
Format and lint with what the runtime ships
Link to section: Format and lint with what the runtime shipsHere is the whole formatter config of this site, from
deno.json:
"fmt": { "semiColons": false },
One option. Everything else is Deno's default: 2 spaces, double quotes, 80
columns. My shared libraries spell it out, in
spy4x/ts-libs/deno.jsonc:
"fmt": {
"useTabs": false,
"lineWidth": 100,
"indentWidth": 2,
"singleQuote": false,
"semiColons": false,
"proseWrap": "preserve"
},
"lint": {
"rules": {
"tags": ["recommended"]
}
},
useTabsandindentWidth: spaces, two of them.lineWidth: 100 columns instead of the default 80.singleQuote: false: double quotes, the default, written down so nobody wonders.semiColons: false: no semicolons, except where JavaScript needs one.proseWrap: "preserve": leave line breaks in Markdown alone. The default,always, rewraps every paragraph to the line width.lint.rules.tags: therecommendedrule set. Deno's linter has over 100 rules; the tags switch on groups of them.
This site adds the fresh tag for its web framework and turns one rule off,
in the same deno.json:
"lint": {
"rules": {
"tags": ["fresh", "recommended"],
"exclude": ["react-no-danger"]
}
},
react-no-danger flags dangerouslySetInnerHTML, and the site uses it on
purpose to render its own Markdown posts and JSON-LD. Turning a rule off is
fine. Doing it in one visible place, instead of in a comment on every line, is
the point.
The commands:
deno fmt # format everything
deno fmt --check # change nothing, exit non-zero if a file isn't formatted
deno lint # report problems
deno lint --fix # fix what the rules can fix
deno fmt covers TypeScript, JavaScript, JSON, Markdown, CSS, HTML, YAML and
more, per its
reference page. Vue, Svelte,
Astro and SQL need an unstable flag. No package, no plugin, no version to keep
in sync with your editor.
Where .editorconfig fits
Link to section: Where .editorconfig fits.editorconfig is the file most editors read for indentation and line endings.
deno fmt reads it too, but only to fill gaps: indentation (indent_style,
indent_size, tab_width), line width (max_line_length) and line endings
(end_of_line). The order is CLI flags first, then deno.json, then
.editorconfig, then defaults.
I checked it with Deno 2.9.7:
printf 'root = true\n\n[*]\nindent_style = tab\nquote_type = single\n' > .editorconfig
printf 'function greet(name: string) {\n return "Hi, " + name\n}\n' > a.ts
deno fmt a.ts && cat a.ts
With no deno.json, the file comes out indented with a tab, as .editorconfig
asked, and still with double quotes. Add
{ "fmt": { "useTabs": false, "semiColons": false } } as deno.json, run it
again, and you get two spaces and no semicolon: deno.json wins.
That quote_type line isn't made up for the example. This site's own
.editorconfig
says quote_type = single, while the code uses double quotes, and
deno fmt --check passes. The formatter never reads that line.
So .editorconfig is for what it is good at: editors that don't run the
formatter on save, and files the formatter doesn't touch, like shell scripts.
Its indentation and line-ending settings reach deno fmt too, unless
deno.json sets them.
mig's
is a good shape to copy: spaces, two of them, LF line endings, UTF-8, trim
trailing whitespace except in Markdown. The style rules themselves live in one
place, the formatter's config.
Make the check a gate, not a habit
Link to section: Make the check a gate, not a habitA formatter on save is a convenience. What keeps a codebase consistent is a
check that fails the build. This is the CI step of my dotfiles repository,
.woodpecker.yml:
steps:
check:
image: denoland/deno:2.9.7
commands:
# The image has neither `git` nor `ps`; the env-key-copy and sweep-orphans tests run both.
# The secrets-backup test runs `restic`; the demo-video encoder test runs ffmpeg and fc-match.
- apt-get update -qq && apt-get install -y -qq --no-install-recommends git procps restic ffmpeg fontconfig
- deno --version
- deno fmt --check
- deno lint
- deno task test
Here is that step in a real run, pipeline 368:
Format and lint come first, so a style slip fails the run before the tests even
start. This site's CI runs one task instead, deno task check, which is
deno fmt --check . && deno lint . && deno check followed by the tests.
Two more things make it a gate rather than a suggestion:
The main branch is protected. A pull request merges only when the Woodpecker check is green, admins included, and nothing pushes to
maindirectly. On GitHub that is one required status check in the branch protection settings.A local hook for fast feedback. Seed, my SaaS starter, has a task that installs a pre-commit hook running the same check, in its
deno.jsonc:"hooks:install": "deno run -R=deno.json,deno.jsonc,.git/hooks/ -W=.git/hooks/ jsr:@hongminhee/deno-task-hooks@0.1.2", "hooks:pre-commit": "deno task check"A hook can be skipped with
git commit --no-verify, so it's a convenience too. CI is the gate.
Compared with my 2022 setup, Husky and lint-staged are gone: the task runner and the formatter are already in the runtime.
AI agents pass the same checks
Link to section: AI agents pass the same checksI run several AI coding agents at once. Their rules live in one file in my dotfiles, rendered for each harness. Two parts of those rules matter here.
First, an agent may not make a check easier to pass. From
ai-harnesses/AGENTS.md:
Never add, remove, reword or weaken a rule, and never remove or weaken a check (lint or format config, a
checktask, a CI step), unless I asked for that change in chat.
So when a lint rule fails, the fix goes into the code, not into deno.json.
Second, an agent may not merge around the check. A Claude Code hook in the same
repository reads every shell command an agent runs, before it runs, and refuses
gh pr merge --admin, the flag that skips branch protection,
in rules.ts:
function mergeVerdict(words: string[]): Verdict {
if (words.includes(`--admin`)) {
return {
deny: `Never pass \`--admin\` to \`gh pr merge\`: the branch protection is the owner's. ` +
`Wait for the check with \`timeout 900 gh pr checks <n> --repo <owner>/<repo> ` +
`--watch\`, or leave the PR open and say so.`,
}
}
The rest is the same gate a person gets: the agent runs the check locally the way CI does, with an empty cache (the rule), and CI runs it again on a clean machine.
Why bother? An agent writes a lot of code quickly, and a formatter turns that code into the same shape as mine, so a diff shows only what changed in meaning. A linter catches the careless kind of mistake before a reviewer, human or agent, spends time on it. And a rule written only in prose is a rule an agent can misread; a red CI run can't be misread.
If you're not on Deno: Prettier, Biome and dprint
Link to section: If you're not on Deno: Prettier, Biome and dprintEach one's latest release and docs as of 30 October 2026:
| Tool | Release | Does | CI command |
|---|---|---|---|
| Prettier | 3.9.10 | Formats; lint is a separate tool | prettier --check |
| Biome | 2.6.4 | Formats and lints in one tool | biome ci |
| dprint | 0.64.3 | Runs formatter plugins from one CLI | dprint check |
Prettier formats JavaScript, TypeScript, CSS, HTML, Markdown and YAML, plus Vue, Angular, MDX and Handlebars templates. It has stopped adding options on purpose, which is a feature. It doesn't lint, so a real setup is Prettier plus ESLint (10.13.0 this month): two tools, two configs, two commands in CI.
Biome formats and lints JavaScript, TypeScript, JSON, CSS, GraphQL and more
as one tool with one config file. Its site claims 97% compatibility with
Prettier's output and 670 lint rules. biome ci runs the format check, the
linter and its assist actions without changing a file, and
biome migrate prettier --write and biome migrate eslint --write carry your
existing configs over.
dprint is a formatting platform: one CLI and one config that run plugins,
mostly WebAssembly, for TypeScript, JSON, Markdown, TOML, Dockerfiles and more,
and wrappers around Prettier, Biome, Ruff and others for the rest. It caches
results, so repeat runs are fast. It doesn't lint. Fun fact: deno fmt itself
is built on dprint's TypeScript, JSON and Markdown plugins, compiled into the
binary; they're in
Deno 2.9.8's Cargo.toml.
What I'd pick:
- A new JavaScript or TypeScript project on Node or Bun: Biome. One tool
and one command is the closest thing to
deno fmtplusdeno lint. - A repository with many languages (TOML, Dockerfiles, Markdown, Python): dprint, with a linter per language next to it.
- A Prettier and ESLint setup that already works: keep it. Add
prettier --check .andeslint .to CI and make the step required. That gate is worth more than switching tools.
Your turn
Link to section: Your turnOpen your CI config. If it doesn't run a format check and a linter, or a red run doesn't block the merge, fix that before the next pull request, whether a person or an agent writes it.
My whole agent setup, rules and hooks included, is public: see how it works. And if you want a senior engineer to look through your codebase and tell you what to fix first, that's my code and security audit.
Get the next post by email
Anton Shubin
Senior Full-Stack Engineer & Tech Lead
I build greenfield SaaS on a modern, lightweight stack, alone or with a team of senior developers from my own pool, and I cover full-stack, DevOps and architecture.
New posts on decisions for founders, developer craft, AI and MCP, self-hosting, and my own mistakes, about once a week at most, and only when there is one. Unsubscribe with one click.
Questions, or a different result? Reply by email: hi@antonshubin.com. It comes straight to me.
