# Performance Optimization Strategies for Writing High-Speed, Syntax-Only Rules in TSSLint

> Boost TSSLint performance by using syntax-only rules that access the TypeScript AST. Learn strategies to speed up linting by avoiding type-checker APIs.

- Repository: [Johnson Chu/tsslint](https://github.com/johnsoncodehk/tsslint)
- Tags: performance
- Published: 2026-03-04

---

**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`](https://github.com/johnsoncodehk/tsslint/blob/main/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:

```ts
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:

```ts
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`](https://github.com/johnsoncodehk/tsslint/blob/main/packages/cli/lib/worker.ts) sets up the two language services (full + syntax-only) and proxies them for the linter:

```ts
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`](https://github.com/johnsoncodehk/tsslint/blob/main/fixtures/noConsoleRule.ts) demonstrates these optimization strategies in practice:

```ts
// 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`, and `getEnd` without touching the type checker.
* **No program access** – Never references `ctx.program` or `ctx.typescript.getTypeChecker()`.
* **Uses `ts.forEachChild`** for 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`](https://github.com/johnsoncodehk/tsslint/blob/main/packages/core/index.ts) | Core linter implementation, mode detection logic, `rule2Mode` caching, and retry mechanism. | [view source](https://github.com/johnsoncodehk/tsslint/blob/master/packages/core/index.ts) |
| [`packages/cli/lib/worker.ts`](https://github.com/johnsoncodehk/tsslint/blob/main/packages/cli/lib/worker.ts) | Worker process that initializes both language services and creates the proxies enabling the fast path. | [view source](https://github.com/johnsoncodehk/tsslint/blob/master/packages/cli/lib/worker.ts) |
| [`fixtures/noConsoleRule.ts`](https://github.com/johnsoncodehk/tsslint/blob/main/fixtures/noConsoleRule.ts) | Reference implementation of a high-performance syntax-only rule following all optimization guidelines. | [view source](https://github.com/johnsoncodehk/tsslint/blob/master/fixtures/noConsoleRule.ts) |
| [`packages/config/lib/utils.ts`](https://github.com/johnsoncodehk/tsslint/blob/main/packages/config/lib/utils.ts) | Helper utilities for rule definition (`defineRule`) and configuration loading. | [view source](https://github.com/johnsoncodehk/tsslint/blob/master/packages/config/lib/utils.ts) |

## Summary

* **Stay in the fast path** by accessing only the AST (`ts.SourceFile`) and avoiding `ctx.program` or type-checker APIs.
* **Use `ts.forEachChild`** for 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 `FileLintCache` for 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`](https://github.com/johnsoncodehk/tsslint/blob/main/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.