Performance Optimization Strategies for Writing High-Speed, Syntax-Only Rules in TSSLint
Write TSSLint rules that access only the TypeScript AST via ts.SourceFile and avoid any program or type-checker APIs to ensure execution stays in the fast syntax-only mode, reducing linting time by skipping full type-checking.
TSSLint, the TypeScript linter by johnsoncodehk/tsslint, provides a dual-mode execution engine that can dramatically accelerate linting performance when rules operate purely on syntax. Understanding the performance optimization strategies for writing high-speed, syntax-only rules in TSSLint allows you to build custom lint rules that scale efficiently across large codebases without incurring the cost of full type-checking.
Understanding TSSLint's Dual-Mode Architecture
Syntax-Only vs. Type-Aware Execution
TSSLint operates in two distinct modes. In syntax-only mode, rules access only the AST (ts.SourceFile) without touching the program, type checker, or any type information. This mode is significantly faster because it avoids full type-checking. In type-aware mode, rules utilize the program and type checker (e.g., ctx.program, ctx.typescript.getTypeAtLocation), which provides rich semantic analysis but incurs the full compilation cost.
Automatic Mode Detection in the Core Linter
The core linter in packages/core/index.ts automatically detects whether rules require type information. When createLinter receives a syntaxOnlyLanguageService with a getNonBoundSourceFile method, it checks if any rule has been flagged as type-aware via the internal rule2Mode Map:
const getNonBoundSourceFile = syntaxOnlyLanguageService?.getNonBoundSourceFile;
const typeAwareMode = !getNonBoundSourceFile
|| shouldEnableTypeAware && !Object.keys(rules).some(ruleId => !rule2Mode.has(ruleId));
If getNonBoundSourceFile exists and no rule requires type information, TSSLint stays in the fast syntax-only path.
Whenever a rule throws an exception while a type-aware program is unavailable, TSSLint interprets it as a signal that the rule needs type info, marks it in rule2Mode, and retries with the full program:
catch (err) {
if (!typeAwareMode) {
// Rule is type-aware; retry later with type information.
rule2Mode.set(currentRuleId, true);
shouldRetry = true;
}
…
}
The worker process in packages/cli/lib/worker.ts sets up the two language services (full + syntax-only) and proxies them for the linter:
const proxy = createProxyLanguageService(linterLanguageService);
proxy.initialize(language);
linterLanguageService = proxy.proxy;
const syntaxOnly = createProxyLanguageService(linterSyntaxOnlyLanguageService);
syntaxOnly.initialize(language);
linterSyntaxOnlyLanguageService = syntaxOnly.proxy;
Performance Optimization Strategies for Syntax-Only Rules
Restrict Access to the AST Only
To remain in the fast path, your rule must access only the AST and TypeScript utility functions provided in the RuleContext. Use file (the ts.SourceFile) and typescript (the ts module) for node inspection. Never access ctx.program, ctx.typescript.getTypeChecker(), or any type-checker APIs, as these trigger the slower type-aware mode.
Avoid Implicit Type-Aware Calls
Certain TypeScript AST methods lazily create type information when called. Avoid methods like node.getSymbol() or any property access that might trigger semantic analysis. Instead, rely on syntax-only checks using ts.is* type guards (e.g., ts.isPropertyAccessExpression), node.kind comparisons, and positional methods like node.getStart() and node.getEnd().
Implement Graceful Error Handling
TSSLint interprets thrown exceptions during syntax-only execution as signals that the rule needs type information, causing a costly retry with the full program. To prevent this overhead, write defensive code that avoids throwing errors. Use simple if checks for node validation rather than assertions. If you must catch errors, ensure the code path never reaches a throw that bubbles up to the linter.
Optimize Tree Traversal
Deep recursion on huge files can dominate runtime performance. Use ts.forEachChild(file, callback) for optimized depth-first traversal, as implemented in the built-in noConsole rule. This method is more efficient than manual recursion or for loops over node.getChildren(), and it allows for early exit by returning from the callback when a violation is found.
Minimize String Allocations
Large string concatenations or calls to node.getFullText() create extra strings and memory pressure. Instead, use numeric positions (node.getStart(file), node.getEnd(), node.getWidth(file)) for diagnostics and fixes. This approach reduces garbage collection overhead and improves throughput on large codebases.
Leverage Caching and Pure Functions
TSSLint maintains a per-file lint result cache (FileLintCache) to avoid recomputing results. Ensure your rules are pure functions without external mutable state, allowing TSSLint to reuse cached results across incremental runs. If your rule offers fixes, generate minimal textChanges—preferring a single change over multiple operations—to reduce fix application overhead.
High-Performance Syntax-Only Rule Example
The built-in noConsole rule in fixtures/noConsoleRule.ts demonstrates these optimization strategies in practice:
// fixtures/noConsoleRule.ts
import { defineRule } from '@tsslint/config';
export default defineRule(({ typescript: ts, file, report }) => {
// Efficient depth-first walk
ts.forEachChild(file, function walk(node) {
// Only AST checks – no type checker used
if (
ts.isPropertyAccessExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === 'console'
) {
// Emit a diagnostic using numeric positions
report(
`Calls to 'console.${node.name.text}' are not allowed.`,
node.parent.getStart(file),
node.parent.getEnd(),
).withFix(
// Simple one-change fix
`Remove 'console.${node.name.text}'`,
() => [{
fileName: file.fileName,
textChanges: [{
newText: '/* deleted */',
span: {
start: node.parent.getStart(file),
length: node.parent.getWidth(file),
},
}],
}],
);
}
// Continue recursion efficiently
ts.forEachChild(node, walk);
});
});
Why this rule stays fast:
- Only AST APIs – Uses
isPropertyAccessExpression,isIdentifier,getStart, andgetEndwithout touching the type checker. - No program access – Never references
ctx.programorctx.typescript.getTypeChecker(). - Uses
ts.forEachChildfor optimal traversal. - Early detection – The condition check is cheap, and the callback returns quickly if the condition fails.
Key Implementation Files
| File | Role | Link |
|---|---|---|
packages/core/index.ts |
Core linter implementation, mode detection logic, rule2Mode caching, and retry mechanism. |
view source |
packages/cli/lib/worker.ts |
Worker process that initializes both language services and creates the proxies enabling the fast path. | view source |
fixtures/noConsoleRule.ts |
Reference implementation of a high-performance syntax-only rule following all optimization guidelines. | view source |
packages/config/lib/utils.ts |
Helper utilities for rule definition (defineRule) and configuration loading. |
view source |
Summary
- Stay in the fast path by accessing only the AST (
ts.SourceFile) and avoidingctx.programor type-checker APIs. - Use
ts.forEachChildfor efficient tree traversal and early exit strategies. - Prevent costly retries by handling errors gracefully; thrown exceptions trigger TSSLint's type-aware fallback mechanism.
- Minimize allocations by using numeric positions (
getStart,getEnd,getWidth) rather than string operations for diagnostics. - Write pure functions without external state to leverage TSSLint's
FileLintCachefor incremental performance gains.
Frequently Asked Questions
What makes a TSSLint rule type-aware?
A rule becomes type-aware when it accesses the TypeScript program or type checker through the RuleContext. Specifically, referencing ctx.program, calling ctx.typescript.getTypeChecker(), or invoking methods like node.getSymbol() that lazily trigger semantic analysis forces TSSLint to switch from syntax-only mode to the slower type-aware mode that performs full type-checking.
How does TSSLint handle errors in syntax-only rules?
When a rule throws an exception during syntax-only execution, TSSLint interprets this as a signal that the rule needs type information. The linter catches the error in packages/core/index.ts, marks the rule as type-aware in the internal rule2Mode Map, and retries with the full program. This fallback ensures correctness but adds significant overhead, making defensive coding essential for performance.
Can I use type information in a syntax-only rule if needed?
No, by definition, syntax-only rules cannot access type information. If your rule logic requires type checking, you should design it as a type-aware rule from the start. However, consider whether you can restructure the rule to use syntactic patterns instead—for example, checking for specific identifier names or property access patterns rather than resolving types. If type information is absolutely necessary, accept the performance trade-off and ensure your rule handles errors gracefully to avoid unnecessary retries.
What is the performance difference between syntax-only and type-aware modes?
Syntax-only mode is significantly faster because it operates solely on the Abstract Syntax Tree without invoking the TypeScript type checker. According to the TSSLint architecture, syntax-only rules use the linterSyntaxOnlyLanguageService with getNonBoundSourceFile, which avoids the expensive binding and type-checking phases. Type-aware mode requires a full program instantiation and type checker access, making it orders of magnitude slower for large codebases but necessary for semantic analysis. For maximum throughput, especially in CI environments, keeping rules in syntax-only mode is essential.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →