Static analysis is any check a tool can run on your code without executing it - reading the source, building a model of it, and reporting problems before a single line runs. In a Node.js project that model shows up as three distinct tools working together: a linter (ESLint) that reasons about code structure and correctness, a formatter (Prettier) that reasons about layout, and a type checker (tsc) that reasons about the shapes flowing through your program.
This page is the map before the how-to. Linting Basics gets a flat config running in minutes, and Prettier Integration, Typecheck in CI, and Knip & Dead Code each go deep on one tool. Here, the goal is understanding why Node projects reach for several narrow tools instead of one broad one, and what each is actually looking at when it runs.
Static analysis in Node.js splits into independent concerns - style/layout, structural correctness, and type soundness - each handled by a purpose-built tool rather than one do-everything checker.
Insight: Conflating these concerns is exactly what produces noisy, contradictory tooling - a linter fighting a formatter over indentation, or a "style" complaint that's actually hiding a real bug.
Key Concepts:abstract syntax tree (AST), linting, formatting, type checking, rule severity, autofix.
When to Use: Setting up a new project's quality gates, deciding which tool owns which class of problem, debugging why ESLint and Prettier seem to disagree, or explaining to a team why "just run the formatter" doesn't catch everything.
Limitations/Trade-offs: Static analysis proves the shape of code is sound, never that its behavior is correct - a perfectly linted, perfectly typed function can still return the wrong answer.
Every static analysis tool starts the same way: it parses your source file into an abstract syntax tree, a structured representation of the code's grammar rather than its raw text.
Once a tool has that tree, it can ask precise questions a plain text search never could - "is this variable ever read after it's assigned," "does this function have a code path with no return," "is this promise awaited anywhere." A regex-based check only ever sees characters; an AST-based check sees meaning.
Node's static analysis story splits across three tools because each one is answering a genuinely different question about that tree:
A formatter (Prettier) asks "how should this be laid out on the page" - indentation, line length, quote style, trailing commas. It has an opinion about appearance and nothing else.
A linter (ESLint) asks "is this code structured correctly" - unused variables, unreachable branches, banned patterns, import direction. It has an opinion about correctness and convention.
A type checker (tsc) asks "do the values flowing through this program match the shapes I was told to expect" - it has an opinion about soundness, checked against declared types rather than runtime behavior.
A simple way to hold these apart: if a change to the code would be invisible after running git diff --ignore-all-space, that's formatting's job. If the change alters what the code does without altering how it looks, that's linting's or type checking's job.
// Formatting concern: spacing/quotes - Prettier owns thisconst x={a:1,b:2}// Linting concern: unused variable - ESLint owns thisconst unused = computeSomething();// Type-checking concern: wrong shape - tsc owns thisfunction total(price: number): number { return price; }total("19.99"); // string passed where a number is required
The reason Node projects run these tools separately - rather than one monolithic checker - comes down to how differently they need to work.
Formatting is meant to have no debate left in it. Prettier deliberately supports few configuration options, because the goal isn't "correct" style, it's one style that nobody argues about in code review again. That's a different design goal from a linter, which is supposed to have dozens of individually toggleable rules because teams legitimately disagree on which correctness rules matter to them.
Linting is rule-based and severity-tiered. Each ESLint rule reports at "off", "warn", or "error", and rules compose from multiple sources: eslint.configs.recommended for general JavaScript correctness, typescript-eslint's rules for TypeScript-specific patterns, and plugin rules like import-x/no-restricted-paths for architectural constraints. A flat config file is just an ordered array of these rule sets, later entries overriding earlier ones for the files they match - which is also why file-scoped overrides (relaxing no-explicit-any only inside test/**) are a normal, expected pattern rather than a workaround.
Type checking works on a different axis entirely: it's not a set of toggleable rules, it's one coherent proof.tsc either can verify that every value's declared type matches how it's used, or it can't - there's no "turn off this one type error" the way you'd disable a lint rule, short of an explicit @ts-expect-error escape hatch that has to be justified inline. That's why teams run tsc --noEmit as its own CI step: it's a pass/fail gate, not a tunable list of warnings.
Autofix exists for two of the three, and that difference matters operationally. Prettier's whole output is an autofix - there's no "manual mode." ESLint can autofix a subset of rules deterministically (eslint --fix) but many rules (an unused variable that represents a real bug) require a human decision. Type errors are never autofixed - a shape mismatch means the code's logic needs to change, not its syntax.
// eslint.config.js - each entry is a layer, later ones win for matching filesexport default [ eslintRecommended, ...tseslintRecommended, { files: ["test/**/*.ts"], rules: { "@typescript-eslint/no-explicit-any": "off" } },];
Static analysis only has real teeth once it's enforced somewhere neither habit nor editor extensions can be skipped - which in practice means CI, not a developer's local setup. An editor plugin catches a problem for the person who has it installed and paying attention; a CI gate catches it for everyone, every time, including the PR from someone who disabled their linter extension by accident.
That's also where the three tools' different costs become a real engineering trade-off, not just a philosophical one. Formatting checks are essentially free - comparing output to a canonical form is fast and parallelizes trivially. Plain linting is cheap because it only needs each file's own AST. Type-aware linting - rules like "no floating promises" that need to know a value's actual type, not just its syntax - is meaningfully slower, because it requires the full TypeScript type-checking machinery running underneath ESLint rather than a lightweight parser. Teams commonly scope type-aware rules to src/** and skip them for scripts or generated code specifically to keep that cost bounded.
A fourth category of static analysis sits alongside these three and is easy to miss: structural and dead-code analysis - tools like Knip or dependency-cruiser that don't check any single file in isolation, but instead build a whole-project graph to find code nothing imports, dependencies nothing uses, or import directions that violate an intended architecture. This is still static analysis (nothing runs), but it operates at the project level rather than the file level, which is why it's a genuinely different tool category from ESLint even though the two get configured similarly.
Finds dead code and architectural violations no single-file tool sees
Needs whole-project traversal; more setup, occasional false positives
Larger codebases and monorepos accumulating cruft
The trend in the Node ecosystem has been toward running more of this earlier and more strictly: flat config replaced the older .eslintrc cascade specifically to make rule composition explicit rather than implicit directory-walking, and "zero-warning" CI policies (--max-warnings 0) have become common precisely because a warning nobody is required to fix is a warning nobody reads.
"ESLint and Prettier are competing tools - pick one." They check different things entirely; the common failure mode is a lint rule that also enforces style, which conflicts with the formatter. The fix is turning off any ESLint stylistic rules and letting Prettier own layout exclusively.
"If the linter passes, the code is correct." Linting proves the code avoids a specific list of known bad patterns - it says nothing about whether the logic produces the right answer.
"Type checking is just a stricter form of linting." They're structurally different: linting is a list of independently toggleable rules, type checking is one coherent proof over the whole program's declared shapes.
"Autofix means I don't need to read the diff."--fix is deterministic for mechanical rules, but it can also silently change behavior for rules with multiple valid fixes - always review an autofix diff before committing it.
"Running this locally is enough - I'll remember to fix warnings." Local-only enforcement is optional by construction; only a CI gate applies the same rule to every contributor on every change.
What's the actual difference between a linter and a formatter?
A formatter only changes how code looks (whitespace, quotes, line breaks) and never changes behavior. A linter can flag - and sometimes fix - things that change what the code actually does, like an unused variable or an unreachable branch.
Why does ESLint need an AST instead of just reading text?
Text alone can't answer structural questions like "is this variable used" or "does every code path return a value." An AST gives the tool a real model of the code's grammar, so it can reason about relationships between parts of the file, not just characters.
Why is Prettier deliberately low on configuration options?
Its entire purpose is to end style debates by producing one canonical layout for everyone. Extensive configurability would recreate the very debate it exists to remove.
Is type checking a form of linting?
Not really - a linter runs many independently toggleable rules over one file's AST, while a type checker proves one thing: that a program's actual value shapes match its declared types, and it does that using both the syntax tree and the type information across imported files.
Why is type-aware linting slower than regular linting?
Type-aware rules need the full TypeScript compiler's type information, not just a file's own syntax tree, so ESLint has to build and consult that project-wide type model before it can evaluate the rule.
Can autofix break my code?
For most mechanical rules, no - the fix is a safe, deterministic transformation. But some rules have more than one valid fix, and applying one automatically can change behavior in a way a human reviewer would have caught, so autofix output should still be reviewed.
Why run type checking as a separate CI step instead of inside ESLint?
Because it's a different kind of check with a different cost profile - a pass/fail proof over the whole program rather than a tunable set of file-scoped rules - so most teams isolate it (tsc --noEmit) to keep its cost and its signal easy to reason about on its own.
What does "static" in static analysis actually mean?
That the check happens without running the program - the tool only reads and reasons about source code, as opposed to dynamic analysis (tests, profilers) that observes the code while it executes.
Is dead-code detection linting?
It's static analysis, but at a different scope - a linter reasons about one file's AST, while dead-code and dependency-graph tools like Knip reason about the whole project's import graph to find code or dependencies nothing actually uses.
Why do teams enforce "zero warnings" instead of just fixing errors?
A warning left unresolved trains everyone to ignore warnings, including the next one that matters. Treating warnings as build failures keeps the signal meaningful.
Does static analysis replace tests?
No - it proves the shape of code is sound (no unused code, correct types, no banned patterns), never that its behavior matches intent. A well-typed, lint-clean function can still compute the wrong result, which only a test can catch.
Why does flat config matter for how these tools compose?
Flat config represents rule sets as an explicit, ordered array where later entries override earlier ones for the files they match, replacing the older implicit directory-cascade model - making it clear exactly which rules apply to which files instead of relying on how .eslintrc files nested.