Toolingimplementation

Linters

A compiler error means the code violates the language rules. A lint means the code is suspicious, unidiomatic, or probably-wrong-but-legal. The boundary between them is not fixed — it moves by ecosystem, and knowing where yours put it explains most of your tooling.

The question

Why is one problem a compiler error, an almost identical one a warning, and a third one something I have to install a separate tool to find?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

The same AST and symbol table the compiler built, walked by a set of independent rules, each producing zero or more diagnostics with a span, a severity and — in the good ones — a machine-applicable fix. What distinguishes a lint from a compile error is not the representation and not the machinery; it is the *severity policy* attached to the result and who gets to change it.

What this phase may assume or do

A lint rule may assume the program parsed. Beyond that, what it is allowed to assume depends on where it runs: a rule inside the compiler may use resolved symbols and types, while a rule in a standalone tool that only parses may use neither, and a type-dependent check written without type information is guessing. The harder precondition is on autofixes: an automatically-applied fix is legal only if it preserves the program's behavior for every input — which is why a fix that removes an "unused" variable whose initialiser has a side effect is a bug, not a lint. Any rule offering a fix must state which of the two it is: behavior-preserving, or a suggestion that requires a human.

Key points

  • An error means the language rules were violated; a lint means the code is legal but suspicious, unidiomatic or probably not what you meant.
  • The boundary is not principled and moves by ecosystem: Go folds vet-like checks into the toolchain, Rust ships lints inside the compiler with levels, JavaScript pushes almost everything to ESLint.
  • A warning and a lint are usually the same analysis with a different severity policy and a different process boundary.
  • Rust's allow/warn/deny/forbid levels make severity a project decision rather than a property of the check, at the cost of compile time and a larger stability surface.
  • A linter without type information cannot check anything type-dependent, which is why typescript-eslint needs a program and why type-aware rules are the slow ones.
  • Autofixes are only legal when behavior-preserving; a fix that deletes an "unused" value with a side effect is a defect in the rule.
  • Lint sets rot through wholesale adoption and blanket suppression. Diff-only enforcement, required reasons and deleting bad rules are what prevent it.
  • Formatting belongs to a formatter. Mixing it into the lint stream is what makes real findings unreadable.

Three categories that get called the same thing

It is worth separating them, because they have different authority and different failure modes.

A compiler error is a claim that the code violates the language definition. It is not negotiable, there is no severity dial, and the program does not exist until it is fixed. A compiler warning is a claim that the code is legal but almost certainly not what you meant — an unused result, a comparison that is always true, a switch missing a case. It is legal, so it must be suppressible; it comes from the compiler, so it has full type information. A lint is a claim about style, idiom, project convention or a defect pattern the language has no opinion about: prefer this API, do not shadow this name, this promise is not awaited, this regex is catastrophic.

The third category is not less important than the first — a missing await is a real bug and no language forbids it — it is just *not decidable from the language definition*, and that is the only distinction that survives across ecosystems.

  • Error: violates the language rules. Non-negotiable, no severity dial, no program.
  • Warning: legal, but the compiler has strong evidence you did not mean it. Suppressible, and it has types.
  • Lint: legal, and the judgement comes from a community or a project rather than from the language. Configurable, and it may or may not have types.
  • Formatting: not a judgement about correctness at all. Belongs in [[formatters]], and mixing it into the lint set is what makes lint output unreadable.
  • The severity is policy. The analysis underneath a warning and a lint is frequently identical machinery running in a different process.

The boundary moves — here is where four ecosystems put it

implementationThis table is a snapshot and the lines move. Go added vet to go test in 1.10 and has been folding more checks into the toolchain since; Rust regularly promotes a clippy lint into rustc, and has moved lints from warn to deny across editions; TypeScript has taken on a few checks that were ESLint rules (noUnusedLocals, noImplicitOverride) while explicitly refusing others. Any claim that "X is a compiler error and Y is a lint" is a claim about one language at one version, and the direction of travel is generally toward the compiler.

There is no principled line between "the compiler should reject this" and "a separate tool should mention it", and the ecosystems have made genuinely different choices. Reading the table below as four defensible answers rather than one right one is the point of the lesson: it explains why a Go engineer finds ESLint configuration bizarre, and why a JavaScript engineer finds Go's refusal to compile an unused import petty.

What actually varies is three things: how much the compiler is willing to reject outright, whether the checks live in the compiler process or a separate one, and whether the severity is per-project policy or fixed by the language.

Where each ecosystem draws the lineimplementation
EcosystemCompiler rejectsIn-toolchain checksSeparate toolWho sets severity
GoUnused local variables and unused imports — hard errorsgo vet ships with the toolchain and runs automatically under go teststaticcheck, golangci-lint for the wider setThe language, mostly. Very little is configurable, by design.
RustType, borrow and lifetime violationsSeveral hundred lints ship *inside* rustc with levels allow/warn/deny/forbidclippy for the opinionated set, also as a rustc driverThe project, via #![deny(...)] and [lints] in Cargo.toml
JavaScript / TypeScriptSyntax only in JS; types too under tscAlmost nothing — tsc deliberately stays out of styleESLint carries essentially the entire lint surfaceThe project, via a config file with hundreds of rules
C / C++Standard violations, and less than you would hope-Wall -Wextra warnings, which are effectively lints with typesclang-tidy, cppcheck, include-what-you-useThe build, via warning flags and -Werror

Rust's levels are the design worth stealing

Rust's answer deserves attention because it dissolves the category question rather than answering it. Lints live in the compiler, so they have full type and borrow information, and each carries a level: allow, warn, deny, forbid. The level is set by the lint's default, overridden per crate, per module or per item by an attribute, and overridden again on the command line. forbid is the one that cannot be downgraded further down the tree.

The consequence is that "is this an error or a lint" stops being a property of the check and becomes a property of *your project*. A team that wants unused_must_use to be fatal writes #![deny(unused_must_use)]. A team migrating a large codebase sets a new lint to warn and ratchets it to deny per module as it cleans up. The same machinery, the same diagnostics, a policy dial per project.

The cost is not zero and it is worth naming: every lint in the compiler is compile time paid by every user on every build, the lint set becomes part of the language's stability surface (a new lint that fires on existing code is a breaking change in practice, which is why new lints arrive as allow and are promoted over editions), and the compiler grows a great deal of code that is not about compiling.

The same check at four different severities, decided per scope
1#![warn(clippy::pedantic)] // whole crate: pedantic lints warn
2#![deny(unused_must_use)] // whole crate: ignoring a Result is an error
3
4#[allow(clippy::too_many_arguments)] // this one function is exempt, on purpose
5fn build(a: u8, b: u8, c: u8, d: u8, e: u8, f: u8, g: u8, h: u8) {}
6
7// On the command line, without touching the source:
8// cargo clippy -- -W clippy::pedantic -D warnings
9// In Cargo.toml, as project policy rather than source noise:
10// [lints.clippy]
11// pedantic = "warn"

The important part is the #[allow] with a reason: a suppression scoped to one item, visible in review, and removed by the compiler warning unused_attribute if the lint stops firing there. Compare with a blanket --no-verify or a config-file rule disabled globally, where nobody can tell later whether the exemption was ever justified or is still needed.

Why lint sets rot, and what stops it

Every lint configuration has the same life cycle. It starts small and useful. Somebody adds a rule set wholesale — airbnb, pedantic, --enable=all — and the noise floor rises. Suppression comments appear. A rule that produces one false positive per week is disabled globally rather than per-case. A year later the config is four hundred lines, half the rules are off, nobody knows why any individual line is there, and new findings are indistinguishable from the standing noise.

The failure is the same one as [[static-analysis]]: precision is an attention budget. But linters have two specific aggravating factors. First, style rules and defect rules go through the same output stream, so a genuine bug report sits between two complaints about import order — which is the strongest argument for delegating everything formatting-shaped to a formatter and deleting those rules entirely. Second, lint rules are cheap to write and cheap to enable, so the set grows by default rather than by decision.

What actually works is unglamorous: adopt with a frozen baseline and enforce on the diff only; require a reason on every suppression; treat a rule that is suppressed more than a handful of times as a rule to delete rather than a rule to enforce harder; and keep formatting out of the linter entirely. And the highest-leverage move, when you have a project-specific invariant, is to write one custom rule for it — a rule about *your* codebase that no general set contains is worth more than fifty imported ones.

  • Delegate all formatting to a formatter; delete the style rules that overlap. This alone usually halves the output.
  • Adopt new rules diff-only against a baseline, never as a repository-wide failure.
  • Require a reason on suppressions: // eslint-disable-next-line rule -- why, #[allow(lint, reason = "...")], //nolint:rule // why.
  • Delete rules with a bad local precision record instead of arguing about them. A disabled rule and a deleted rule cost the same and only one is honest.
  • Write custom rules for your own invariants. That is where a linter earns more than a general rule set ever will.
  • Autofixes make a rule far cheaper to adopt — but only where the fix is genuinely behavior-preserving.

How it works

The steps, in the order the compiler takes them.

  • The tool parses the source — ideally into a concrete syntax tree so that comments and exact positions survive for suppression directives and fixes.
  • It resolves names and, where available, types, either by embedding a compiler frontend or by asking one (typescript-eslint starts a TypeScript program; clang-tidy uses a real Clang AST from the compilation database).
  • Each rule registers for the node kinds it cares about, and the tool performs one traversal, dispatching to every interested rule — which is why the marginal cost of an extra syntactic rule is small and the cost of a type-aware one is not.
  • A rule reports a diagnostic: a span, a message, a severity from configuration, and optionally one or more text edits forming a fix.
  • Suppression directives found in the trivia — comments — filter the diagnostic set before it is reported, which is only possible because comments were preserved.
  • Fixes are applied in a loop until a fixed point, with overlapping edits rejected, because two rules editing the same range cannot both be applied blindly.
  • The remaining diagnostics are formatted for a human, for an editor over [[lsp]], or as a machine-readable report for CI.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • A rule with a 20% false-positive rate is disabled globally after a month, taking its 80% of real findings with it, and nobody records why.
  • An autofix rewrites catch (e) {} to remove an "unused" binding and silently changes which exceptions are caught, because the rule reasoned syntactically about something semantic.
  • CI fails on a lint error in a file the author never touched, because a rule was upgraded in a transitive dependency of the lint config, and the team adds --no-verify to the commit hook.
  • The lint run takes four minutes because type-aware rules re-create the whole program, and developers stop running it locally, so every finding arrives in CI at the worst moment.
  • A blanket /* eslint-disable */ at the top of a file, added to unblock one line, silently disables every rule in a two-thousand-line module forever.
  • Two linters and a formatter disagree about the same construct, and the codebase oscillates between the two forms on alternate commits.

When it helps

  • Defect patterns the language cannot express: floating promises, missing await, useEffect dependency mistakes, comparisons of incompatible units, misuse of an internal API.
  • Project-specific invariants encoded as custom rules — the only category where a linter provides something no general tool ever will.
  • Consistency across a large team, where a mechanical rule replaces a recurring review comment and removes an entire class of tedious human disagreement.
  • Migration and deprecation: a lint plus an autofix turns "please stop using this API" into a codemod that runs in CI, which is the difference between a deprecation that happens and one that does not.

When it hurts

  • When the rule encodes taste rather than defect risk, and the ratio of style rules to bug rules gets high enough that people stop reading the output.
  • On generated, vendored or migrated code, where thousands of pre-existing findings drown the ones that matter unless the tool supports a baseline.
  • When type-aware rules push lint time past the interactive threshold, at which point the analysis stops being a fast feedback loop and becomes a CI gate.
  • As a substitute for design. A lint enforcing "no direct database access in handlers" is much weaker than a module boundary that makes it impossible.

What it costs

Every one of these is paid by something.

  • Putting lints in the compiler (Rust) buys full type information and one process, and costs compile time on every build for every user plus a stability commitment to the lint set.
  • Putting lints in a separate tool (ESLint) buys independent release cadence and a plugin ecosystem, and costs a second parse, a second configuration surface, and rules that guess about types unless you pay for type-aware mode.
  • Making everything an error (Go's unused imports) buys uniformity and zero configuration, and costs the ability to leave a scratch variable in place while debugging.
  • Making severity configurable buys per-project policy and costs a configuration file that becomes a permanent maintenance surface and a source of cross-repository inconsistency.
  • Autofixes buy adoption and codemod capability, and cost the correctness burden of proving a fix is behavior-preserving — plus the risk that a wrong fix is applied to a thousand files before anybody reads one.
  • A large imported rule set buys instant coverage and costs a noise floor that suppresses the signal from the rules you actually chose.

What else you could do

What a different compiler or language does instead, and when that is better.

  • Push the check into the type system so it cannot be ignored: non-nullable types, #[must_use], exhaustive matching, branded types for units. Stronger, and it costs language expressiveness and a migration.
  • Push it into the compiler as a warning, which gets you type information and a single process, at the cost of the language owning a judgement it may not want to own.
  • Push it into the architecture: module boundaries, visibility, an API that makes the wrong thing unrepresentable. Always better than a rule when it is available.
  • Use a formatter for everything formatting-shaped, and delete those rules — see [[formatters]]. This is the highest-value alternative and the most commonly skipped.
  • Use review for judgements that are genuinely contextual. A rule with 60% precision applied automatically is worse than a reviewer who reads the case.
  • Write the check as a query in a general analysis language — CodeQL, Semgrep, ast-grep — when the rule is one-off or exploratory and does not justify a plugin.

See it for yourself

The flag, dump or tool that shows you this directly.

  • JavaScript/TypeScript: npx eslint . --max-warnings=0, npx eslint --print-config file.ts to see what is actually enabled after all the extends, and TIMING=1 npx eslint . to find which rules are costing you the run time.
  • Rust: cargo clippy -- -W clippy::pedantic -D warnings; rustc -W help lists every lint the compiler ships with its default level; cargo clippy --fix applies the machine-applicable ones.
  • Go: go vet ./..., staticcheck ./..., golangci-lint run --enable-all to see the ceiling before curating downward.
  • C/C++: clang-tidy --list-checks -checks='*', then clang-tidy -p build/ --checks='-*,bugprone-*,performance-*' src/*.cpp; --fix applies fixes and --export-fixes=fixes.yaml lets you review them first.
  • Python: ruff check --select ALL --statistics prints a per-rule count, which is the fastest way to decide what to enable; ruff check --fix and mypy --strict cover the two halves.
  • Cross-language and ad hoc: semgrep --config=auto, ast-grep run -p '$A == $A', or CodeQL when the query needs data flow rather than a pattern.
  • For any of them, run the "print the effective config" command once. Almost every surprising lint result is a config inherited from an extends chain nobody has read.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "If it were important, the compiler would reject it." The compiler rejects what the language defines as invalid. A missing await is a real bug in every language that has one, and no language definition forbids it.
  • "Lints are about style." Style is one category. Floating promises, resource leaks, always-true comparisons and unsafe regex patterns are defect detection that happens to ship in a linter.
  • "More rules means better code." Past a precision threshold more rules means more suppressions and less attention on the rules that were working.
  • "An autofix is safe because a tool wrote it." An autofix is safe only if the rule proved it behavior-preserving. Several widely used fixes are explicitly documented as suggestions, not guarantees.
  • "We should lint formatting too, for consistency." A formatter makes formatting non-negotiable and produces no diagnostics at all. Every formatting lint you keep is noise in the channel where real findings appear.

Misconceptions

The claim, and what is actually true.

Warnings are just weak errors.
They are claims about legal code. A language cannot make them errors without rejecting valid programs, which is why they are suppressible and why -Werror is a project policy rather than a language feature.
A linter is a simpler static analyser.
It is the same static analyser with a different distribution model and severity policy. Type-aware lint rules run the full frontend; the difference is who decides whether a finding blocks the build.
Disabling a noisy rule is admitting defeat.
A rule you suppress everywhere is already disabled, dishonestly. Deleting it makes the configuration describe what is actually enforced.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

A compiler error means your code broke a rule of the language. A lint means it is legal but looks wrong — a variable that shadows another, a promise nobody waited for, an API your team decided not to use. The confusing part is that different languages draw the line in different places: Go refuses to compile an unused import, Rust ships hundreds of lints inside the compiler with a dial for how serious each one is, and JavaScript pushes essentially everything into ESLint. None of them is doing it wrong; they made different choices about how much the language should insist on.

practical

Two moves fix most lint configurations. Move all formatting to a formatter and delete every rule that overlaps — that is usually half the output and all of the arguments. Then check the precision of what remains: any rule you suppress more than a handful of times is not earning its noise, and deleting it is more honest than the suppressions. After that, spend the budget you freed on one or two custom rules for your own invariants, because those are the only rules that encode something no general set contains. And pin the linter version in CI, for the same reason you pin the compiler.

advanced

The architectural question underneath is where the lint rules should live, and it is a real trade rather than a matter of taste. Inside the compiler you get types, one parse, spans that are already correct, and integration with the language server for free — and you pay compile time for every user, and you make the lint set part of the language's compatibility surface, which is why new rustc lints ship as allow and are promoted only at edition boundaries. Outside the compiler you get an independent release cadence and a plugin ecosystem, and you pay a second parse, a second configuration surface, and either no type information or the cost of reconstructing it. Rust's hybrid — clippy as a rustc driver, so it reuses the frontend without shipping in the compiler's stability guarantee — is the most interesting available answer, and it is worth understanding as a distribution decision rather than a technical one.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

implementationEvery concrete claim here is version-specific. Go promoted vet into go test in 1.10; Rust moves lints between clippy and rustc and between levels across editions, so cargo clippy output differs between toolchains on identical source; ESLint 9 changed the configuration format entirely, so advice about .eslintrc does not apply to eslint.config.js. Pin your linter version in CI for the same reason you pin your compiler: an unpinned linter turns an upstream release into a red build on code nobody touched.
typicalThe claim that lint rules without type information cannot check type-dependent properties describes the standard architecture, where a rule sees only a syntax tree. Tools do escape it: typescript-eslint in type-aware mode constructs a real TypeScript program and clang-tidy uses a real Clang AST from a compilation database. Both are substantially slower than the syntactic mode, which is exactly the trade being made — a purely syntactic rule about types is guessing from names.
specThe distinction that survives across all ecosystems is decidability from the language definition: an error is a claim the standard or reference makes, a lint is a claim a community makes. Everything else — process boundary, severity, configurability — is convention. That is why "should this be an error?" is answerable only as "does the language define this as invalid?", and every other framing produces an ecosystem-specific answer.

If you were asked this in an interview

  • What is the actual difference between a compiler error, a warning and a lint?
  • Your lint config is four hundred lines and half the rules are disabled. What do you do?
  • When is an autofix safe to apply automatically, and give me an example of one that is not.

Connections

Domains that do not exist yet
  • DevOps / Production Engineering — Running linters as a pipeline gate: baselines, diff-only mode, and pinning the tool version
    Most lint failures are operational rather than analytical — an unpinned linter version turning an upstream release into a red build on untouched code, or a wholesale rule adoption blocking every merge. How a check is staged in the pipeline is owned there; what each rule can actually prove is ours.
  • Testing & Reliability Engineering — Where a lint sits relative to unit tests and review in a defect-prevention strategy
    A lint, a test and a review comment can all catch the same defect at very different costs, and choosing which one owns a given class is a quality-strategy decision owned there. What the linter is technically capable of proving — and what it is guessing at without type information — is ours.