Skip to content

Instantly share code, notes, and snippets.

@johnsoncodehk
Last active May 1, 2026 03:24
Show Gist options
  • Select an option

  • Save johnsoncodehk/dcd318b51b9102da97032abb73b11975 to your computer and use it in GitHub Desktop.

Select an option

Save johnsoncodehk/dcd318b51b9102da97032abb73b11975 to your computer and use it in GitHub Desktop.
TSSLint 3.1: running ESLint rules at 16× speed

Running ESLint rules at 16× speed in TSSLint

On Dify web/ (5860 .ts / .tsx files), running one type-aware rule:

Wall (min of 3) Peak RSS
ESLint Linter 25 s 7.0 GB
TSSLint 3.1 1.5 s 3.75 GB

Native linters (Rust's Oxlint and the like) hitting 10× is normal — that's the cross-runtime gap. JS-vs-JS usually lands at 1.5-3×. This 16× is the same V8, the same ESLint rule source, the same ESTree shape — the gap is all architecture. 3.1 is a full rewrite of @tsslint/compat-eslint (the compatibility layer that lets stock ESLint rules run inside TSSLint). Bench: tsslint-dify-bench.

Scope: this package only serves the "use stock ESLint rules" path. TSSLint native rules go straight to ts.Node + ts.TypeChecker and bypass compat-eslint's selector dispatch / lazy ESTree / scope-manager — that's a separate path.

The problem

For "lint a lot of TS files + want type-aware" workloads, ESLint burns time on two things: selectors are registered once per session and never change, but they get re-evaluated dynamically against every node — pure waste. typescript-estree also eagerly walks the entire ESTree before lint even starts. The goal: rules untouched, API compatible, dispatch path rebuilt as a hot path.

Side by side

The two pipelines from source to diagnostic:

ESLint + typescript-eslint                    TSSLint 3.1
─────────────────────────                     ───────────

source file                                   source file
    │                                             │ (already in
    ▼                                             │  host's ts.Program —
@typescript-eslint/parser                         │  no rebuild)
    │ builds own ts.Program /                     │
    │ ProjectService (2 copies vs host)           │
    ▼                                             ▼
typescript-estree                             tsScanTraverse
    │ EAGER full walk → entire ESTree             │ predicate hit?
    │ (28k nodes built)                           │   miss → recurse
    ▼                                             │   hit  → materialize
ESLint Linter                                     │          LazyNode
    │ build SourceCode (indexMap /                │          (children lazy)
    │              line splits /                  ▼
    │              scopeManager)              dispatchTarget
    ▼                                             │ Map lookup by .type
SourceCodeTraverser                               ▼
    │ walk visitor-keys all nodes             listener (inline)
    ▼                                             │
SourceCodeVisitor                                 ▼
    │ EventEmitter bucket lookup              diagnostic
    ▼
esquery
    │ per-node × per-selector
    │ dynamic graph match
    │ (parent.type? :has? attr?)
    ▼
listener (try/catch wrap per call)
    │
    ▼
diagnostic

Every on the left is a per-node cost. The right has only two: tsScanTraverse's predicate, and dispatchTarget's Map lookup. The three lines below crack the right pipeline open: how selectors get pre-decomposed, how the TS AST is walked, how dispatch goes inline.

Line 1: Selector — dynamic eval replaced with precomputed tables

ESLint rule listeners register esquery-string selectors:

'Identifier'
'Identifier:exit'
'Identifier[name="foo"]'
'CallExpression > MemberExpression'
'FunctionDeclaration BlockStatement'
':matches(A, B, C)'
'CallExpression:has(Literal)'
':function'
'Parent > Child.field'

ESLint internally buckets listeners by node type and caches parsed selector ASTs. Even so, every visited node still pays the esquery evaluator — recursive checks asking "current node? parent.type? subtree has X?". Across 28k nodes × dozens of selectors, the total match count runs into the millions per file.

But selectors register once and don't change within a lint session — they can be flattened at registration into a type set + predicate function, removing all runtime graph matching.

decomposeSimple flattens each selector into a FastDispatchInfo struct:

interface FastDispatchInfo {
  types: Set<string> | 'all';     // node.types this fires on
  isExit: boolean;
  fieldFire?: string;              // single-level: trigger on parent, listener gets parent[fieldFire]
  fieldChain?: string[];           // multi-level: A > B.f1 > C.f2 walks [f1, f2]
  fieldChainTypes?: (string | undefined)[];  // paired with fieldChain, intermediate type checks
  typeFilter?: string;             // extracted node must match this .type
  filter?: (target: any) => boolean;  // generic per-target predicate
}

The whole esquery expressivity collapses into these fields — 5-7 selector forms map cleanly:

Selector types fieldFire / fieldChain filter
'Identifier' {Identifier}
'Identifier[name="foo"]' {Identifier} n => n.name === 'foo'
'CallExpression > MemberExpression' {MemberExpression} n => n.parent?.type === 'CallExpression'
'FunctionDeclaration BlockStatement' {BlockStatement} n => walk ancestors for FunctionDeclaration
'CallExpression:has(Literal)' {CallExpression} n => walk subtree for Literal
':function' {Func/FuncExpr/ArrowFunc}
'Parent > *.body' {Parent} fieldFire 'body'
'A > B.f1 > C.f2' {A} fieldChain [f1, f2]

Covers every selector form rules actually use (ESLint + typescript-eslint catalogue 100%) — not the full esquery grammar. Uncovered shapes throw UnsupportedSelectorError, never falling back to NodeEventGenerator — gaps are loud and force themselves to be filled.

buildFastDispatch buckets a pile of FastDispatchInfo by types into a type-keyed Map<string, DispatchEntry[]>, plus two wildcard arrays (enterAll / exitAll) and a codePath event Map. Hot path: Map lookup + a few short-circuit ifs (fieldFire / fieldChain → typeFilter → filter; all undefined skips straight to listener call). Simple selectors are 80%+ of the catalogue; for those every if short-circuits.

Line 2: Traversal — walk TS AST, lazy materialize ESTree

The fast tables from Line 1 are keyed on ESTree types — Identifier / MemberExpression / ImportDeclaration. But tsserver hands us a ts.SourceFile made of ts.SyntaxKind enums and TS-shaped child layouts, structurally similar to ESTree but with 170+ divergences:

ts.SourceFile          ↔  Program
ts.VariableStatement   ↔  VariableDeclaration   (names swapped)
ts.PropertyAccess      ↔  MemberExpression
ts.BinaryExpression    ↔  BinaryExpression / LogicalExpression / AssignmentExpression
                          (one-to-three, depending on operator)

typescript-estree's approach is to convert eagerly — every ts.Node gets a matching ESTree node (54k-line checker.ts produces ~28k nodes). We don't. Materialization timing, lazy children downward, lazy .parent upward, structural wrappers handled in both directions — four things, in that order. Shape first:

ts.SourceFile (top-down via tsForEachChild)
  │
  visit(node) {
    predicate hit?
      ├── NO  ──── tsForEachChild → recurse children   (no ESTree object cost)
      │
      └── YES ──── materialize(node)
                     │
                     ▼
                 LazyNode { type, _ts, parent }       ← children not built yet
                     │
                     ├ enterCb(LazyNode)              ← fires :enter listener
                     ├ tsForEachChild → recurse
                     └ leaveCb(LazyNode)              ← fires :exit listener
  }

bench: checker.ts × 43-rule, ~10-20% hit rate
       28k ts.Node walked → ~5-8k LazyNode materialized

Once materialized, a LazyNode's children are still lazy:

  IfStatementNode {
    type: 'IfStatement', _ts, parent
    get test         ←──── built only when a rule reads it
    get consequent   ←──── never built if no rule reads
    get alternate    ←──── same
  }

  rule reads node.test.left.name:
    .test → BinaryExpressionNode (new LazyNode)
    .left → IdentifierNode       (new LazyNode)
    .name → straight from _ts.left.text (string, not LazyNode)

Walk TS AST, materialize on hit

Deciding "fire a listener?" only needs ts.Node.kind — not the ESTree object itself:

const visit = (node: ts.Node, parentTarget): void => {
  const hit = predicate(node);
  if (hit) {
    const target = materialize(node, ctx);   // build LazyNode here
    enterCb(target);
  }
  tsForEachChild(node, child => visit(child, target ?? parentTarget));
  if (hit) leaveCb(target);
};

predicate(node) decides via ESTree-type → ts.kind mapping — most are pure kind matches (IfStatement / BinaryExpression); a few need modifier / operator checks (LogicalExpression looks at the operator token; TSAbstractMethodDefinition looks at the abstract modifier).

Each LazyNode child field is a getter — node.test builds a BinaryExpressionNode the first time it's read and caches it; never read, never built. Bench: checker.ts × 43-rule averages ~5000-8000 LazyNodes built (vs eager ~28000+).

esTreeNodeToTSNodeMap becomes a facade over WeakMap

ESLint rules occasionally call astMaps.esTreeNodeToTSNodeMap.get(estreeNode) to look up the corresponding ts.Node. typescript-estree keeps two WeakMaps for forward and reverse mapping — every ESTree node built does two set() calls.

But every LazyNode already stores its ts.Node in the _ts field — the forward set is fully redundant. esTreeNodeToTSNodeMap becomes a facade object: get(estreeNode) returns estreeNode._ts, set is a no-op. ~9k fewer WeakMap.set calls per file.

Bottom-up parent chain (upward)

LazyNode children can be lazy, but parent can't — the constructor needs the parent reference for super, and rules read .parent constantly.

The naive approach is recursing materialize(tsNode.parent): each call is a fresh frame. We replace that with one walk up + one build down, three phases:

materialize(tsNode):
  (a) if tsNode is wrapper-routed — parent connects through a synthetic ESTree wrapper
        (TSTypeAnnotation / ChainExpression / ..., see §Structural wrappers below):
        materialize(owner) → trigger owner's slot getter → done
  (b) otherwise walk up the parent chain, skip structural wrappers, find nearest cached ancestor:
        while walker: skip SyntaxList/CaseBlock/...; cache hit → break;
                      else toBuild.push, walker = walker.parent
  (c) build downward: from the cached anchor, new LazyNode all the way down,
        constructor auto-registers in cache.

SourceFile is pre-registered in the cache — the walker never loops; every LazyNode built is auto-cached. Naive recursion against an ESTree depth of 30+ pays 30 stack frames per .parent access; one walk + reverse build pays 30 once, then ancestor cache hits in 1 step. Plenty of rules read .parent.

Structural wrappers: two directions

The ESTree / TS AST mismatch has two directions.

ESTree adds a synthetic wrapper (no TS kind):

  • ClassDeclaration → ClassBody → members — TS just has ClassDeclaration { members } directly, no intermediate ClassBody
  • TSInterfaceDeclaration → TSInterfaceBody → members — same
  • ExportNamedDeclaration { declaration: VariableDeclaration } — TS is a VariableStatement with an export modifier
  • ChainExpression { expression: MemberExpression } — TS is PropertyAccess?.foo at the outermost level
  • TSParameterProperty { parameter } — TS is a Parameter with a public/readonly modifier
  • TSTypeQuery { exprName: TSImportType } — TS is an ImportType with isTypeOf=true

These ESTree types live in WRAPPER_HEAD_TYPES. On hit, unwrapChain pulls out the chain, enters them in order / leaves in reverse, with child visits in the middle. For inner types like ClassBody (no own ts.kind), SIMPLE_KINDS reverse-maps them to the head's kind (ClassBody → ClassDeclaration / ClassExpression, TSInterfaceBody → InterfaceDeclaration, etc.) — registering a 'ClassBody' listener marks ClassDeclaration's kind in the predicate; on hit, chain expansion enters both layers, and dispatch fires from each type's bucket.

TS AST adds a structural wrapper (no ESTree counterpart):

  • SyntaxList — TS parser internal marker
  • CaseBlockSwitchStatement.cases is a flat array in ESTree; TS wraps with CaseBlock
  • VariableDeclarationList inside a VariableStatement — folded into VariableStatement → ESTree VariableDeclaration
  • NamedImportsImportDeclaration.specifiers[] is direct ImportSpecifiers in ESTree
  • ImportClause without .name — no default specifier, no ESTree counterpart at all
  • Other position-dependent cases: Block inside ClassStaticBlockDeclaration (StaticBlock is the block), VariableDeclaration inside CatchClause (catch param is the Identifier directly), HeritageClause(extends), left-side comma BinaryExpression flattened into outer SequenceExpression

If the walker materialized all of these, the same source range would fire enter multiple times. Two skips: the downward walker (tsScanTraverse) doesn't enter, just recurses; the upward walker (materialize parent chain) skips and continues toward an ancestor.

The two skip sets are structurally symmetric but not identical: ImportClause in the downward walker has to inspect .name (a default name's .name is itself an ImportDefaultSpecifier to dispatch on), while the upward walker doesn't care and skips unconditionally. Missing a skip surfaces as enter firing twice or two independent LazyNode trees being built.

Line 3: Dispatch — fast tables replace the emitter

Dispatch is one inline path. The visitor decides whether to wrap CPA based on whether any rule registered an onCodePath* listener:

  • Non-CPA mode (default) — visitor calls dispatchTarget(target, isEnter, ...) directly on enter / leave.
  • CPA mode — visitor forwards enter / leave to CodePathAnalyzer; CPA emits when it should emit, calls enter / leave when it should, and every hook dispatches inline to dispatchTarget or its codePath listener array. CPA's call order is the dispatch order — no step queue mediates.

Both modes share dispatchTarget + runEntries. The only difference is whether the visitor wraps a CPA layer.

dispatchTarget:

function dispatchTarget(target, isEnter, fast, errors, onTarget) {
  onTarget(target);  // both phases update currentNode
  const arr = (isEnter ? fast.enter : fast.exit).get(target.type);
  if (arr) runEntries(arr, target, errors);
  const allArr = isEnter ? fast.enterAll : fast.exitAll;
  if (allArr.length) runEntries(allArr, target, errors);
}

The action is a Map lookup + entry walk. runEntries short-circuits per entry: rule already errored → skip (a listener throw writes to the errors map; subsequent calls for the same rule all skip — rules don't run half-baked); fieldFire extracts a child (null / array → skip), fieldChain walks multiple fields with intermediate type checks (any null / array / type mismatch → skip); typeFilter matches; filter matches; only then the listener call. Simple selectors (80%+) skip all the ifs.

Compare ESLint: every visited node goes through one layer of listener-registry type-bucket dispatch, then every selector in the bucket runs through esquery's evaluator for complex conditions, then individual listener calls (each with its own try/catch). We inline the call, the listener leaves the array directly. Across 5860 files × 28k visits × N selectors, that framework layer is what's in the way.

Lazy token / comment

typescript-estree eagerly builds sourceCode.tokens / sourceCode.comments (checker.ts: 348k tokens, 6.7k comments — cold ~50ms + ~115ms / file). Most rules never read them; we made them lazy — never read, never paid.

CPA: inlined into the walker

The control flow analyzer (CodePathAnalyzer) provides CFG segment objects for onCodePathStart / onCodePathEnd / onCodePathSegmentStart and the like. Rules like no-unreachable / no-fallthrough / require-atomic-updates use it.

CPA's emit and enter / leave order are tightly coupled (startCodePath → emit → forwardCurrentToHead → emit → original.enterNode); internal state must update during the walk — has to be inline, but doesn't need a buffer-then-replay step, hooks dispatch directly. In ESLint, CPA wraps the event generator — CPA's enterNode handles CFG state, then forwards to the original generator. We stuff dispatchTarget straight into the wrapper, then feed the wrapper to CPA:

const wrapped = {
  emit:    (name, args) => dispatchEvent(name, args),  // codePath listener arrays
  emitter: { emit: (name, ...args) => dispatchEvent(name, args) },
  enterNode: t => dispatchTarget(t, true,  fast, errors, onTarget),
  leaveNode: t => dispatchTarget(t, false, fast, errors, onTarget),
};
new CodePathAnalyzer(wrapped);

dispatchEvent uses the same errors.has short-circuit + try/catch wrapping a single listener call as runEntries — same shape. The CPA wrapper is under 30 lines and shares one dispatchTarget with non-CPA mode.

CPA mode uses predicateAllKinds (CPA's preprocess(node) reads node.parent.type — the walker can't narrow).

Dependencies retreat (aside)

After three lines, runtime dependencies go from eslint + @typescript-eslint/{visitor-keys,typescript-estree,scope-manager} down to just @tsslint/types + esquery. What can be inlined as a static table is inlined; what can be redone via ts.checker is redone; what can be vendored is vendored. esquery's 1500-line grammar parser stays untouched — the principle is "don't rewrite what doesn't need rewriting".

This part isn't a speedup (the speed wins are already in the three lines above); it's about decoupling. When ESLint changes its internal emitter / SourceCode shape, this layer doesn't have to follow. The compatibility layer's responsibility ends at the public rule API.

Numbers

With 3.0.4 in the table to see the delta:

Wall (min of 3) Peak RSS
TSSLint 3.0.4 23 s 7.0 GB
TSSLint 3.1 1.5 s 3.75 GB
ESLint Linter 25 s 7.0 GB

3.1 vs 3.0.4 is ~15× — the cumulative result of replacing typescript-estree and ESLint Linter machinery with our own lazy implementation.

3.1 vs ESLint Linter is ~16× — both sides are fed the same ts.Program (via programs: [program] to typescript-eslint parser, controlling for variables), so this 16× is the dispatch hot path's three things: lazy ESTree, selector decomposition into fast tables, inline dispatch (no emitter / step queue).

But this 16× is correlated with rule count. The bench runs a single rule, narrow trigger, ~10-20% hit rate — lazy ESTree saves the most when most nodes never materialize. Wider production rule sets push hit rates up and compress lazy's win; the speedup shrinks as you stack rules.

profile (node --prof) own-code time:

TS internals (binder / checker / scanner):    50-60%
ESLint rule code + plugin internals:          20-25%
ESLint SourceCode / TokenStore + CPA:         10-15%
compat-eslint own code (what we can move):     7.3%

Where it stops

7.3% is the floor. Even cleaning up all our own code only leaves ~7% to gain — realistically ~3% extractable; below that we're reaching for V8 internals, unrelated to "how lint runs". The remaining 92% of CPU is in TS internals, ESLint rule code, plugin internals — not on our side. The 16× bench number is for "narrow rule" workloads (wider rules compress the gap, see §Numbers); but "the part we can move has hit its ceiling" doesn't shift with workload.

Looking back, the three lines say one thing — "no hit, no read, no ask, no pay". Trigger doesn't fire → no materialize, child not read → no build, parent not walked → no construction; tokens and selectors are extensions of the same principle. What's left to save isn't on our side anymore.

Present

ESLint picking ESTree as the base wasn't a constraint of the era — TSLint, contemporaneous, walked TS AST directly. ESLint chose ESTree because it was the established AST standard in the JS ecosystem; the cost is TS support stuck in the "TS AST → ESTree" conversion gap — I think the trade-off no longer holds after TS went mainstream. TSSLint doesn't break it either, just shrinks the gap to its minimum: lazy, materialize on hit.

TSSLint is a successor to TSLint's spirit — after TSLint deprecated in 2019, "go straight to TS AST" had no active carrier; TSSLint picks it up via the tsserver plugin form. Which is also why I don't see this as ESLint ecosystem's long-term answer. It only holds when three premises align — ESLint is still the community default, you need custom type-aware rules, the IDE wants sub-100ms response. The day base AST aligns with TS, or a native linter integrates into tsserver, TSSLint should retire.

This rewrite was almost entirely written, debugged, and profiled by an LLM; I just guided. Replacing the entire typescript-estree + ESLint Linter machinery and standing up a byte-identical parity bench across packages — the workload didn't pencil out for a single maintainer pre-AI. 3.1 is the version AI made possible.


Performance series, prior chapter: alien-signals deep dive

@tsslint/compat-eslint source: https://github.com/johnsoncodehk/tsslint/tree/master/packages/compat-eslint

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment