Linting and formatting
Catch bugs with Oxlint and ESLint 10, leave spacing to one formatter, and run both on save, on commit and in CI without anyone having to remember.
What you'll learn
When you finish, you can do these.- 1Catch likely bugs with Oxlint first, then ESLint
- 2Format with Prettier and keep ESLint out of layout
- 3Make the checks run on every save and every commit
- 4Fix a v-for with no key and a rule that fights Prettier
A shopping list page has a quantity box on every row and a button that sorts the list from A to Z. Type 2 next to "Oat milk" in the first row, press the button, and the 2 stays there, next to "Bananas". No error appears. The v-for that repeats the row has no :key, the attribute that tells Vue which item each row belongs to. A linter reads code without running it and reports patterns known to cause bugs, like this one. A formatter decides where spaces, quotes and line breaks go, so every file looks the same.
create-vue, the official project generator, sets up both. The Code tab holds their config files, the package.json scripts that run them, and ShoppingList.vue, the list with its key added; by the end you can read each file and make the checks run by themselves. You need one idea from the lesson Editor and Vue DevTools: create-vue's .vscode/settings.json makes the VS Code editor format a file and apply lint fixes when you save it.
Linters find bugs: Oxlint first, then ESLint
A create-vue project runs two linters: Oxlint, which is fast, then ESLint, slower but able to check Vue templates.
ESLint 10 reads eslint.config.ts, a list of entries that each say which files to check or which rules apply. Here is the file's core; the Code tab adds the imports:
export default defineConfigWithVueTs(
{ name: 'app/files-to-lint', files: ['**/*.{vue,ts,mts,tsx}'] },
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
...pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
skipFormatting,
)
From the top:
- The first two entries pick the files:
.vueand TypeScript files, but not folders such asdist, where the built app goes. flat/essential, fromeslint-plugin-vue, holds the Vue rules for likely errors, such asvue/require-v-for-keyandvue/multi-word-component-names.vueTsConfigs.recommendedadds the TypeScript rules, the source of messages like'Button' is defined but never used.- The last two turn off the ESLint rules that Oxlint and the formatter take care of.
defineConfigWithVueTs, from @vue/eslint-config-typescript, wraps the whole list so these rules can read <script lang="ts"> in .vue files.
Oxlint runs the same kind of rules as ESLint in a fraction of the time. Its config file, .oxlintrc.json, turns on the correctness group: rules for code that is plainly wrong. In if (list.length = 0), one = stands where === was meant, and Oxlint reports eslint(no-cond-assign): Expected a conditional expression and instead saw an assignment.
buildFromOxlintConfigFile, from eslint-plugin-oxlint, reads .oxlintrc.json and switches off in ESLint every rule Oxlint already runs, so each rule runs once.
In package.json, the lint script runs both: pnpm lint (pnpm is the package manager) starts run-s "lint:*", which runs lint:oxlint and then lint:eslint.
One formatter, and ESLint keeps out
One tool, and only one, decides the layout. Prettier, the formatter create-vue sets up, reads its settings from .prettierrc.json: no semicolons, single quotes, lines up to 100 characters. pnpm format rewrites every file in src/ to match.
create-vue can set up Oxfmt instead, an experimental formatter from the makers of Oxlint. Pick one, never both.
ESLint must not format either. skipFormatting, from eslint-config-prettier, turns off every ESLint rule with an opinion about layout, such as where semicolons go. It sits last in eslint.config.ts because, when two entries set the same rule, the later one wins: a style rule added after it is on again and fights Prettier.
Make it automatic: save, commit, push
Nobody runs a linter by hand for long, so each stage of your work runs the checks for you: the editor on save; git, the tool that records versions of your code, on each commit; and CI, a server that checks every push.
- saveformat
- commitlint-staged
- pushCI lint, type-check, test
- merge
On save, VS Code formats the file and applies lint fixes, as .vscode/settings.json tells it to.
On commit, git first runs a pre-commit hook, a command the simple-git-hooks package installs. The hook runs lint-staged, which runs Oxlint, ESLint and Prettier on the staged files only, the ones going into the commit, so a commit takes seconds. In package.json, the prepare script and two blocks set this up:
"prepare": "simple-git-hooks"
"simple-git-hooks": {
"pre-commit": "pnpm lint-staged"
},
"lint-staged": {
"*.{ts,vue}": ["oxlint --fix", "eslint --fix", "prettier --write"]
}
The prepare script runs after every install, so everyone who installs the project gets the hook. When you edit the simple-git-hooks block later, run pnpm exec simple-git-hooks once: git runs the hook file inside .git/hooks/, and only that command rewrites it.
On push, CI runs pnpm lint, the type-check and the tests on the whole project. The next lesson, Environment variables, builds and deployment, writes that workflow.
What goes wrong
Two mistakes look harmless at first: a list with no key, and a rule that fights Prettier.
The first is in the Code tab's shopping list, ShoppingList.vue, with a quantity box per row and a Sort A to Z button. Take its key away, and the v-for reads:
<li v-for="item in items">
{{ item.name }} <input type="number" min="0" placeholder="Qty">
</li>
The page looks right and nothing warns in the browser. Without a key, Vue reuses rows by position when the list changes: type 2 next to "Oat milk", press Sort A to Z, and the 2 stays in the first row, next to "Bananas".
ESLint does warn: Elements in iteration expect to have 'v-bind:key' directives (:key is short for v-bind:key). A unique id per item fixes it:
<li v-for="item in items" :key="item.id">
{{ item.name }} <input type="number" min="0" placeholder="Qty">
</li>
With the key, each row moves with its item, and the 2 goes with "Oat milk" to the last row.
The second mistake is a style rule added after skipFormatting, here a teammate's preference for semicolons:
skipFormatting,
{ rules: { semi: ['error', 'always'] } },
)
ESLint reports Missing semicolon and eslint --fix adds them; Prettier, set to "semi": false, removes them. pnpm lint and pnpm format undo each other on every file, every time. Delete the rule, so skipFormatting is last again:
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
skipFormatting,
)
Style choices, semicolons included, belong in .prettierrc.json.
What to remember
- A linter reports likely bugs; a formatter only moves spaces, quotes and line breaks. You need both, and one of each.
- Oxlint runs first and fast;
eslint-plugin-oxlintswitches its rules off in ESLint, so nothing runs twice. - Style choices live in the formatter's config, and
skipFormattinggoes last ineslint.config.ts, so ESLint stays out of layout. - The editor formats on save,
simple-git-hookspluslint-stagedcheck the staged files on every commit, and CI the whole project on push. After editing the hook config, runpnpm exec simple-git-hooks. vue/require-v-for-keycatches a missing:key, a bug that renders without any warning; a unique id per item fixes it.
In your project
Do these in a real project. Each one says how to confirm it worked.- In any component, add
<li v-for="n in 3">{{ n }}</li>and runpnpm lint. It reportsElements in iteration expect to have 'v-bind:key' directives, and VS Code underlines the line. Add:key="n"and run it again: the error is gone. - Install
simple-git-hooksandlint-staged, add the config from the Code tab, and runpnpm exec simple-git-hooks. Commit a file containingexport const greet = (name:string)=>{ return "Hello, "+name ; };. The commit output listsoxlint --fix,eslint --fixandprettier --writewith ticks, andgit showshows the line asreturn 'Hello, ' + name. - Time both linters with
time pnpm exec oxlint .andtime pnpm exec eslint .. Compare thereallines: Oxlint's is a fraction of ESLint's.