DEV Community

SEN LLC
SEN LLC

Posted on

Counting TypeScript Escape Hatches — A Zero-Dependency CLI with a Baseline Gate

Counting TypeScript Escape Hatches — A Zero-Dependency CLI with a Baseline Gate

Teams migrating JS → TS reach for any, @ts-ignore, and as unknown as X for perfectly good reasons. But without a CI gate, those escape hatches accumulate and the migration never actually finishes. I built a tiny CLI that counts them, enforces a baseline, and takes up 136 MB of Alpine.

📦 GitHub: https://github.com/sen-ltd/ts-migrate-lint

ts-migrate-lint output

ts-migrate-lint is a Node 20 CLI with zero runtime dependencies that scans TypeScript source for six categories of escape hatch, reports per-file breakdowns, and fails CI when any category goes up compared to a baseline you commit. The scanner is about 200 lines of hand-written state machine. No TypeScript compiler, no AST, no ts-morph, no micromatch. The whole thing exists because I was tired of watching any counts climb every sprint while the engineering team insisted the migration was "almost done."

This post is about three things:

  1. Why a token-aware regex scanner is enough for this narrow job, and why I stopped reaching for the TS compiler.
  2. The baseline pattern — the monotonic-improvement enforcement trick that I think every long-running refactor needs.
  3. What it doesn't do, and when you should actually use tsc --noEmit instead.

The problem: migrations that never end

If you've ever been on a team that decided to migrate 400 JavaScript files to TypeScript, you know the shape of the graph. Week 1: 400 files, everything typed any. Week 4: 350 files converted, 50 @ts-ignore scattered around. Week 12: 389 files converted, 117 @ts-ignore, nobody can remember which ones are temporary.

Every one of those escape hatches was added for a good reason. A library had no types. A third-party response shape was genuinely unknown. A test helper was 400 lines of dynamic dispatch. The migration happens in one sprint and the cleanup is supposed to happen "later."

The problem is that "later" never triggers. Nobody opens a PR titled "remove 40 any from the codebase" unless something forces them to. The escape hatches are the water level in a pot that nobody is watching.

What you want is a ratchet. A mechanism where the number can go down, and can stay the same, but cannot go up. That's what ts-migrate-lint --baseline does.

The scanner: why regex is enough

The first version of this tool used typescript as a dependency and walked the AST. It worked. It was also 50× slower and had a 4 MB node_modules. When I actually looked at what I was counting — six specific syntactic patterns — the AST was massive overkill.

Here's the entire state machine:

type State =
  | 'NORMAL'
  | 'BLOCK_COMMENT'
  | 'STRING_SINGLE'
  | 'STRING_DOUBLE'
  | 'TEMPLATE'
  | 'TEMPLATE_EXPR';
Enter fullscreen mode Exit fullscreen mode

Six states. The scanner walks the source one character at a time and maintains exactly two things: the current state and a stack for nested template expressions (because `outer ${`inner ${x as any}`}` is a thing people write).

The interesting part is what each state suppresses. In NORMAL we do full pattern detection. In STRING_SINGLE, STRING_DOUBLE, TEMPLATE, and BLOCK_COMMENT we do nothing except look for the closing delimiter. This is how we avoid the classic naive-regex false positive:

const message = "this string contains the word any"  // not counted
const sql = `SELECT * FROM users WHERE id = ${id}`   // `${` enters TEMPLATE_EXPR
const code = /* any */ 42                             // block comment, not counted
Enter fullscreen mode Exit fullscreen mode

Line comments are a special case. We want to detect // @ts-ignore inside line comments because that's where the directive lives. So the line-comment handler isn't "skip to \n" — it's "read the comment body, check for @ts-ignore / @ts-expect-error / @ts-nocheck as a prefix, then skip to \n":

if (ch === '/' && next === '/') {
  let end = i + 2;
  while (end < source.length && source[end] !== '\n') end++;
  const body = source.slice(i + 2, end);
  const trimmed = body.trimStart();
  if (/^@ts-ignore\b/.test(trimmed)) {
    hits.push({ category: 'ts-ignore', line, column: col, text: '// ' + trimmed });
  } else if (/^@ts-expect-error\b/.test(trimmed)) {
    hits.push({ category: 'ts-expect-error', line, column: col, text: '// ' + trimmed });
  } else if (/^@ts-nocheck\b/.test(trimmed)) {
    hits.push({ category: 'ts-nocheck', line, column: col, text: '// ' + trimmed });
  }
  advance(end - i);
  i = end;
  break;
}
Enter fullscreen mode Exit fullscreen mode

That snippet is why I didn't need the TS compiler. The compiler would hand me a comment node and I'd still have to regex-match the directive. Saving 40 MB of node_modules to do the same thing.

The any detector

any is the one category where a naive match fails catastrophically. anyone, Company.any, return any; (where any happens to be a variable name — yes, that's valid JS) — any of these would false-positive a word-boundary regex.

The solution is to require any to appear in a type position, which I approximate as: the previous non-whitespace character is one of : < , ( | & [ > =. That catches x: any, Array<any>, any[], any | string, (x: any) => void, T extends any, let x: any = …, and so on.

The one special case is the cast expression x as any. The character before any is s, not one of the trigger characters, so I added an explicit as keyword check:

// Accept `as any` cast: preceding token is the `as` keyword.
if (prev === 's' && j >= 1 && source[j - 1] === 'a' && !isIdent(source[j - 2])) {
  return true;
}
Enter fullscreen mode Exit fullscreen mode

This is the kind of code that would make a parser author grimace and a pragmatist nod. It's exactly what you'd write if you were scanning for a known shape and didn't care about the edge cases you weren't scanning for.

What about as unknown as X?

The triple-token cast as unknown as X is the unofficial "I know this is wrong but I need it to compile" pattern. Detecting it is three token walks with whitespace skipping:

function matchUnknownCast(source: string, i: number): number {
  if (source.slice(i, i + 2) !== 'as') return 0;
  if (isIdent(source[i - 1])) return 0;
  if (isIdent(source[i + 2])) return 0;

  let p = i + 2;
  while (p < source.length && /\s/.test(source[p]!)) p++;
  if (source.slice(p, p + 7) !== 'unknown') return 0;
  if (isIdent(source[p + 7])) return 0;
  p += 7;
  while (p < source.length && /\s/.test(source[p]!)) p++;
  if (source.slice(p, p + 2) !== 'as') return 0;
  if (isIdent(source[p + 2])) return 0;
  p += 2;
  while (p < source.length && /\s/.test(source[p]!)) p++;
  if (!isIdent(source[p])) return 0;
  while (p < source.length && isIdent(source[p])) p++;
  return p - i;
}
Enter fullscreen mode Exit fullscreen mode

No regex here — character-by-character because we need precise control over identifier boundaries. This function is 20 lines and has its own test case. If I'd used a parser I'd still be staring at TypeAssertionExpression in the AST docs.

The baseline pattern: monotonic improvement

Here's the shape of the data we commit as .ts-migrate-lint.json:

{
  "version": 1,
  "generatedAt": "2026-04-16T03:22:00.000Z",
  "totals": {
    "any": 47,
    "ts-ignore": 12,
    "ts-expect-error": 3,
    "ts-nocheck": 0,
    "unknown-cast": 5,
    "non-null-assert": 22
  }
}
Enter fullscreen mode Exit fullscreen mode

Comparison is trivial:

export function compareBaseline(
  baseline: Baseline,
  current: CountRow,
): BaselineComparison {
  const result: BaselineComparison = { increased: [], decreased: [], unchanged: [] };
  for (const c of ALL_CATEGORIES) {
    const before = baseline.totals[c] ?? 0;
    const after = current[c] ?? 0;
    if (after > before) {
      result.increased.push({ category: c, before, after });
    } else if (after < before) {
      result.decreased.push({ category: c, before, after });
    } else {
      result.unchanged.push(c);
    }
  }
  return result;
}
Enter fullscreen mode Exit fullscreen mode

That's it. If any category went up, exit 1 and the CI build turns red. The developer who added a new any now has to either justify the number going up (manually edit .ts-migrate-lint.json in the same PR, which shows up as a very visible diff on review) or remove an any elsewhere in the codebase.

This ratchet works on any count-like metric. I've used the same pattern for lint warning counts, TODO comment counts, skipped-test counts, console.log counts. It's cheaper than a hard "zero allowed" threshold because it lets you start improving today without a big-bang cleanup sprint.

Why not a hard threshold?

You can also pass --threshold any=50. That's a hard cap: if the count exceeds 50, exit 1. It's useful when you genuinely want a ceiling — usually a round number set just above the current value, to be ratcheted down manually.

The threshold evaluator is a switch over a small map:

export function evaluateThresholds(
  totals: CountRow,
  thresholds: ThresholdMap,
): ThresholdViolation[] {
  const violations: ThresholdViolation[] = [];
  let total = 0;
  for (const c of ALL_CATEGORIES) total += totals[c];
  for (const [key, limit] of Object.entries(thresholds) as Array<
    [Category | 'total', number]
  >) {
    const actual = key === 'total' ? total : totals[key];
    if (actual > limit) {
      violations.push({ key, limit, actual });
    }
  }
  return violations;
}
Enter fullscreen mode Exit fullscreen mode

Threshold and baseline compose. You can say "total must be below 200 AND no category may increase." In practice I ship the baseline alone and let developers file issues when they want to tighten a category.

Three output formats

  • human (default): totals table plus the top N files ranked by total count. For local work and sanity-checking.
  • json: full per-file, per-category, with hits array (line + column). For pipelines, metric dashboards, and scripts.
  • github: GitHub Actions annotations, one ::warning per hit. Drops annotations directly on the PR diff so reviewers see them inline.

GitHub annotations are the one place where per-hit location info earns its keep:

export function formatGithub(report: AggregateReport): string {
  const lines: string[] = [];
  for (const f of report.files) {
    for (const h of f.hits) {
      const esc = (s: string): string =>
        s.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
      lines.push(
        `::warning file=${esc(f.path)},line=${h.line},col=${h.column}::ts-migrate-lint: ${h.category}`,
      );
    }
  }
  return lines.join('\n');
}
Enter fullscreen mode Exit fullscreen mode

That's the entire formatter. Stdout, no deps.

Tradeoffs — what this tool is NOT

I want to be very specific about what ts-migrate-lint doesn't do, because some of these are legitimate reasons to reach for a different tool.

No AST means no semantic understanding. If you have type A = any somewhere and then use A everywhere, the scanner catches the definition but not the usages. For "how much of my codebase is effectively typed any" you want type-coverage, which uses the TS compiler and walks symbol tables. ts-migrate-lint counts syntactic escape hatches, which is what you actually see in code review.

No fix automation. Airbnb's ts-migrate rewrites JavaScript to TypeScript and injects @ts-expect-error comments where it has to give up. That's a different tool for a different phase of the migration. ts-migrate-lint runs after you've already converted files and want to stop the bleeding.

No loose-type detection. The scanner doesn't flag Object, Function, {}, or unknown on its own. {} and unknown are legitimate types people use on purpose. Object and Function are loose but they're a different conversation — they'll show up in ESLint's @typescript-eslint/ban-types rule.

False positives on exotic syntax. The scanner treats a ! as a non-null assertion when it's after an identifier and followed by ., [, (, ,, ;, ), ], }, or whitespace. That's most of them. If you've got generator functions with yield! or other unusual patterns, there will be drift. The test suite has 26 scanner cases covering the ones I've hit in real code; if you find a new false positive, open an issue with a repro.

Not a replacement for tsc --noEmit. The TypeScript compiler is still your source of truth for type correctness. This tool is about tracking the disabling of type correctness. Both belong in CI.

Try it in 30 seconds

docker build -t ts-migrate-lint https://github.com/sen-ltd/ts-migrate-lint.git

# Run against a sample project
mkdir -p /tmp/demo/src
cat > /tmp/demo/src/user.ts << 'EOF'
export function parseUser(raw: any) {
  // @ts-ignore
  return raw.id as unknown as number
}
EOF

docker run --rm -v /tmp/demo:/work ts-migrate-lint '/work/src/**/*.ts'
# scanned 1 file(s)  →  any=1 ts-ignore=1 unknown-cast=1  total=3

# Capture as baseline
docker run --rm -v /tmp/demo:/work ts-migrate-lint '/work/src/**/*.ts' \
  --baseline /work/.ts-migrate-lint.json --update-baseline

# Add a new any — baseline fails
echo 'export function g(y: any) {}' >> /tmp/demo/src/user.ts
docker run --rm -v /tmp/demo:/work ts-migrate-lint '/work/src/**/*.ts' \
  --baseline /work/.ts-migrate-lint.json
# exit 1: any: 1 -> 2 (+1)
Enter fullscreen mode Exit fullscreen mode

The runtime image is 136 MB Alpine with no node_modules at all — just the compiled JS and Node. Startup is imperceptible.

Closing

Counting escape hatches is a five-minute engineering problem that becomes a five-year engineering problem if nobody counts them. The tool is 600 lines of TypeScript, 60 tests, zero runtime deps. The whole point is that it's small enough that you can read it in an afternoon, trust it, and add it to CI on day 2 of your migration.

If you want this on your codebase, the repo is at github.com/sen-ltd/ts-migrate-lint. Star it if it saves you a Monday.

Top comments (0)