Deferring type checks in lint rules
Type-aware rules are the main source of lint slowdowns, because calls into TypeScript's checker (getTypeAtLocation, getConstrainedTypeAtLocation, checker.getTypeAtLocation, getTypeName, etc.) are far more expensive than reading the AST that the parser already produced.
A rule visitor often combines two kinds of conditions:
- Syntactic / AST checks — node types, operators, flags, parent shape, option values. These are essentially free: the data already exists in memory.
- Type checks — anything that asks the checker for a
Typeand then inspects it. These can trigger lazy type resolution and are the expensive part.
The win is almost always the same: make sure every cheap check that can reject a node runs before the first expensive type lookup. When a syntactic guard can short-circuit the visitor, a type lookup that would have been thrown away never happens.
When to use
Use this when authoring a new rule in packages/eslint-plugin/src/rules, or when reviewing/refactoring an existing one, and the visitor calls the type checker. It is most impactful on visitors that fire on very common node types (binary expressions, member expressions, calls), since those run constantly.
How to find candidates
- In each rule visitor, locate the first call that retrieves a type. Common names:
getTypeAtLocation,getConstrainedTypeAtLocation,services.getTypeAtLocation,checker.getTypeAtLocation,getTypeName, and helpers built on top of them. - Look at every check that comes after it and could return /
continue/ skip the node. Ask: does this check read only the AST (node type, operator, parent, option, a flag), with no dependency on the type value? - If yes, that check is a candidate to move above the type lookup.
How to apply
Reorder so the cheap, type-independent guard runs first. The behavior must be identical — you are only changing when the type is fetched, never whether the node is reported.
Before — the type is fetched even for nodes the AST guard would reject:
const type = getConstrainedTypeAtLocation(services, node);
if (!tsutils.isTypeFlagSet(type, ts.TypeFlags.VoidLike)) {
return;
}
const invalidAncestor = findInvalidAncestor(node); // pure AST walk
if (invalidAncestor == null) {
return;
}
After — the AST guard rejects first, so the type lookup only runs when it's actually needed:
const invalidAncestor = findInvalidAncestor(node); // pure AST walk
if (invalidAncestor == null) {
return;
}
const type = getConstrainedTypeAtLocation(services, node);
if (!tsutils.isTypeFlagSet(type, ts.TypeFlags.VoidLike)) {
return;
}
Sometimes the cheap and expensive conditions are combined in one &&. Order the operands so the cheap one is evaluated first and can short-circuit:
// Before: getTypeName runs before the free node-type check
} else if (
getTypeName(checker, rightType) === 'string' &&
node.left.type !== AST_NODE_TYPES.PrivateIdentifier
) {
// After: the free check short-circuits before getTypeName
} else if (
node.left.type !== AST_NODE_TYPES.PrivateIdentifier &&
getTypeName(checker, rightType) === 'string'
) {
Do the shared work once
A visitor that walks the scope chain or rebuilds a set of names once per reported node repeats that work every time the rule fires, and real files reach hundreds of matching variables.
When a visitor derives the same data on every call, hoist it into a Map or Set built once in create(). Generating a non-colliding name is the usual example: collecting every declared name then looping for a free suffix costs a full walk per report, where a Map<string, number> of collisions per base name answers it in one lookup.
The reverse caution applies to caches: one keyed on a node only pays off if the same node is asked about twice.
Things to verify before claiming a win
- No behavior change. Reordering must not change what the rule reports. Re-run the rule's existing tests; they should pass unchanged. If a test would need editing, the reorder changed behavior and is wrong.
- No side effects between the moved lines. If the type lookup populated a cache/variable used later, or a guard had a side effect, preserve ordering of those effects.
- The guard is genuinely cheaper. Moving one type lookup ahead of another type lookup is not a win. The point is AST-only guards jumping ahead of type lookups.
- The guard can actually reject. Reordering only helps when the cheap check sometimes short-circuits. If it never rejects in practice, there's no win.
- Measure when in doubt. Benchmark with
hyperfineagainstpackages/eslint-plugin(or a representative project). Gains are typically a few percent per rule, so verify rather than assume.
Reference
- Pattern origin: PR #12296 (defer type checks to improve rule performance) and issue #12370.
- Performance troubleshooting docs.