Home Tech

A TypeScript Custom Transform Cost One Team Its Entire ESLint Migration

L
Lucas Mendes| Jul 16, 2026
crepi.kmoonnews.com · Tech team
A TypeScript Custom Transform Cost One Team Its Entire ESLint Migration

In early 2025, a mid-sized engineering team at a growing fintech company was nearing the finish line of a long-anticipated migration from a legacy linting setup to a modern ESLint-based pipeline. The migration had been planned for months: configuration files were reviewed, rules were tuned, and the team had even run a pilot on a single service. But when they rolled out ESLint across their monorepo, something unexpected happened. The lint pass started passing with zero errors, even on code that clearly violated the new rules. For two weeks, the team shipped code that was technically un-linted, and no one noticed. The culprit: a custom TypeScript transform that had been quietly rewriting source files after compilation, producing an AST that ESLint couldn’t properly parse. This is the story of how a well-intentioned transform cost a team its entire ESLint migration, and what they learned from the aftermath.

The Custom Transform That Broke the Pipeline

The transform in question was a small, inline TypeScript transformer that converted arrow functions to function declarations. The team had adopted it months earlier as part of a code style initiative: they wanted all functions in a certain module to use named declarations for better stack traces and debugging. The transform ran as a custom step in the TypeScript compilation pipeline, between tsc and the output generation. It was a pragmatic solution at the time—no one wanted to refactor hundreds of functions manually.

When the team began their ESLint migration, they configured ESLint to run on the compiled JavaScript output, a common pattern in projects that rely on custom transforms. The assumption was that linting the final artifact would catch any issues introduced by the transform. But ESLint’s parser, @typescript-eslint/parser, was designed to parse TypeScript source, not the transformed output that included function declarations with different scoping and hoisting characteristics. The parser silently fell back to a generic JavaScript mode, skipping several TypeScript-specific rules entirely.

The team’s CI pipeline had been set up with lint warnings as non-blocking, a decision made during the migration to avoid breaking builds. So even when ESLint produced zero errors, no one investigated why. The transform had effectively neutered the entire linting process. The root cause, in hindsight, was clear: no one had reviewed the transform’s impact on downstream tools. It was treated as a build-only concern, invisible to the linting team.

To add context, this team was not alone in relying on custom transforms. Many organizations use similar techniques to enforce coding conventions, optimize bundle size, or inject polyfills. A survey of developer tooling practices published in late 2024 found that roughly 30% of TypeScript projects in large monorepos employ at least one custom transform, often for legacy compatibility or style enforcement. Yet fewer than 10% of those projects have any integration test that validates the transform’s interaction with linting tools. The gap is systemic, and this team’s experience is a cautionary tale for any organization considering a similar approach.

How a Single Plugin Silently Wrecked Linting

To understand the mechanics, consider what happens in a typical build pipeline. TypeScript source files are parsed into an AST, transformed (if custom transforms exist), then compiled to JavaScript. ESLint expects to receive the original source AST—or at least an AST that preserves the syntactic structure the rules were designed to check. The custom transform, however, replaced arrow functions with function declarations, which have different scoping rules and hoisting behavior. ESLint’s parser, encountering a function declaration where it expected an arrow function, could not apply rules like no-invalid-this or prefer-arrow-callback correctly.

Worse, the transform also introduced subtle changes to the AST’s node types. The @typescript-eslint/parser relies on specific node types to match rules. When a rule looked for ArrowFunctionExpression, it found FunctionDeclaration instead, and simply skipped the node. The team later discovered that 47 source files were affected, and three critical rules—no-unused-vars, no-shadow, and prefer-const—had been effectively disabled for those files. The transform had run before lint, so ESLint never saw the original code.

The team only discovered the issue during a manual audit triggered by a developer who noticed that a known lint violation in a pull request was not flagged. They compared lint runs on the raw source versus the transformed output and found the discrepancy. The two-week gap meant that several commits had been merged without proper lint coverage, and the team had to retroactively fix violations that had accumulated.

This kind of silent failure is particularly insidious because it gives a false sense of security. The team saw green CI statuses and assumed their code quality was improving, when in reality they were flying blind. The financial impact, while not catastrophic, was measurable: the team estimated that the accumulated lint violations would have taken roughly 40–60 person-hours to fix if caught early, but the retroactive effort took nearly double that because the violations had been embedded in merged code that required additional refactoring. Moreover, the incident delayed the team’s broader migration by several weeks, as they had to pause rollouts to investigate and remediate.

Post-Mortem: The Missing Integration Test

The post-mortem revealed a classic gap in testing strategy. The team had unit tests for the transform itself—it correctly converted arrow functions to function declarations. They also had unit tests for individual ESLint rules. But they had no integration test that ran the full pipeline: compile, transform, then lint. The transform’s version was pinned to an older ESLint parser that had different behavior, but the lockfile didn’t reflect that dependency—the parser was updated independently during the migration.

The CI pipeline treated lint warnings as non-blocking, a decision made to avoid slowing down development during the migration. But that decision, combined with the missing integration test, created a blind spot. The team later calculated that roughly 15% of the codebase had been shipped without effective linting. The incident led to a broader review of their toolchain testing practices. As one engineer put it during the post-mortem: “We tested the parts, but not the assembly.”

Lessons from this incident echo similar findings in other toolchain failures. For instance, a separate team at the same company had previously encountered a CI budget blowout due to a misconfigured plugin, as covered in our earlier article on plugin fees. The common thread is that toolchain components interact in non-obvious ways, and testing them in isolation is not enough.

Another comparable case involved a team that used a Babel plugin to strip console.log statements from production builds. The plugin inadvertently removed logging calls that were used by their monitoring system, causing a two-day outage in error tracking. That team, too, had unit-tested the plugin but never validated the full pipeline. The pattern is consistent: when a transform modifies the AST, downstream tools like linters, minifiers, and bundlers can break in ways that are hard to predict without end-to-end testing.

Three Practices That Prevent Transform-Induced Breakage

After the incident, the team adopted several practices that have since prevented similar regressions. The first is running lint on pre-transform source as a gate in CI. By linting the raw TypeScript before any custom transforms are applied, the team ensures that the original code meets the project’s standards. The transformed output is then linted separately, but the pre-transform gate catches violations that might be masked by the transform.

The second practice is pinning transform and parser versions together in the lockfile. The team now maintains a single dependency group that includes the custom transform, the ESLint parser, and any related plugins. When any of these is updated, the entire group is tested together. This prevents the silent drift that caused the original failure, where the parser was updated independently of the transform.

The third practice is a smoke test that compares lint output before and after the transform. The team wrote a small script that runs ESLint on a set of representative files, then runs the transform and lints the output again. If the set of violations changes unexpectedly, the build fails. This test is fast enough to run on every commit. Additionally, the team now requires a 48-hour staging period for any new transform, during which it is validated against the full CI pipeline. The transform’s purpose and side effects are documented in a README that is reviewed by at least one other engineer before merging.

These practices are not without trade-offs. Running lint twice—once on source and once on output—increases CI runtime by roughly 15–30 seconds per job, which can add up in large monorepos. The team mitigated this by running the pre-transform lint only on changed files, keeping the overhead minimal. Similarly, pinning versions together can slow down updates if a security fix is needed for only one component, but the team decided that the safety benefit outweighed the inconvenience. The smoke test, while simple, requires maintenance as new rules are added; the team automated its update process to regenerate the expected violation set after each rule change.

Why Most Teams Don't Catch This Until Production

The incident is not an outlier. Many teams treat transforms as build-only concerns, assuming that linting the output is sufficient. But lint rules are designed to understand developer intent, not compiler transformations. When a transform changes the structure of the code, it can invalidate the assumptions that rules are built on. The problem is compounded by the fact that CI pipelines rarely simulate the exact artifact that will be linted in production. Most pipelines run lint on source files before compilation, missing the transform entirely.

Tooling vendors also contribute to the gap. ESLint and TypeScript are developed independently, and neither tests against the other’s custom transforms. The @typescript-eslint/parser is designed to handle standard TypeScript syntax, but custom transforms are, by definition, non-standard. The result is a class of silent regressions that look like code quality improvements—fewer lint errors—but actually represent a loss of coverage. The team’s experience mirrors a pattern seen in other toolchain failures, such as the tokenizer choice that doubled training throughput on identical hardware, as described in this related article.

Another reason teams miss these issues is the prevalence of “cargo cult” CI configurations. Many organizations copy pipeline setups from popular open-source projects without understanding the specific interactions in their own toolchain. For example, a common template runs lint on the dist folder after build, which would catch transform-induced issues if the transform runs during build. But if the transform is applied during compilation (as in this team’s case), the lint step on dist sees the transformed code and misses the original violations. The team had followed a template from a well-known monorepo setup guide, but that guide assumed transforms were applied during bundling, not during TypeScript compilation. The mismatch went unnoticed until the incident.

Furthermore, the incentives in CI design often prioritize speed over correctness. Teams may disable lint on certain steps to reduce build times, or treat lint warnings as non-blocking to avoid developer friction. These decisions are rational in isolation, but they create blind spots when combined with custom transforms. The team in this story had a policy of “green CI at all costs,” which led them to make lint non-blocking during the migration. That policy, while well-intentioned, allowed the silent failure to persist for two weeks. A better approach would have been to keep lint as blocking but allow a grace period for fixing violations, with a clear timeline for enforcement.

Rebuilding Trust in the Toolchain After the Incident

In the months following the incident, the team took significant steps to rebuild confidence in their toolchain. The first decision was to rewrite the custom transform as a standalone ESLint rule instead. Rather than modifying the AST at build time, they created a rule that enforces the use of function declarations directly, allowing ESLint to flag violations in the original source. This eliminated the transform entirely, simplifying the pipeline and removing the source of the breakage.

The team also added a “lint-on-commit” hook that runs ESLint on the raw source before any transformation. This hook is enforced in CI and locally via a Git pre-commit script. They created a dashboard that tracks the lint pass rate per transform version, so any regression is immediately visible. The dashboard also shows which rules are most frequently disabled by transforms, providing a feedback loop for future tooling decisions.

Finally, the team instituted a monthly toolchain audit where engineers from different teams review the CI pipeline for potential blind spots. These audits have uncovered several other issues, including a misconfigured minifier that stripped type annotations and a Babel plugin that reordered imports. The team is now open-sourcing their integration test harness, which includes the pre- and post-transform lint comparison, as a reusable tool for the community. The incident, while costly, ultimately led to a more robust toolchain—one that the team trusts to catch regressions before they reach production.

The broader lesson for the engineering community is that toolchain components are deeply interconnected, and testing them in isolation is insufficient. As build pipelines grow more complex with custom transforms, bundlers, and plugins, the risk of silent failures increases. Teams should invest in integration tests that simulate the full pipeline, and they should treat any transform that modifies the AST as a potential threat to downstream tools. The cost of a few extra seconds of CI time is trivial compared to the cost of two weeks of un-linted code in production.

How do you feel about this?
Happy
Happy
33%
Love
Love
31%
Excited
Excited
27%
Sad
Sad
6%
Angry
Angry
3%
Feedback

Found a problem or have a suggestion? Let us know. You can leave your email for a follow-up.

Tech

One Inference Cluster's GPU Memory Layout Forced a Training Rewind on Every Resharding Event

One Inference Cluster's GPU Memory Layout Forced a Training Rewind on Every Resharding Event

An inference cluster's static GPU memory layout triggered a full training rewind on every resharding event. Here's how one team diagnosed the problem and built a tiered memory fix with lazy migration.

Insurance

Three Reinsurers Paid a Single Marine Cargo Claim on Two Different Loss Estimates

Three Reinsurers Paid a Single Marine Cargo Claim on Two Different Loss Estimates

When a container ship struck a jetty in Santos, Brazil, three reinsurers received two different loss estimates for the same cargo. This article traces how the gap emerged, the contract clause that broke the deadlock, and what it reveals about reinsurance pricing.

Copyright 2019 - 2026 crepi.kmoonnews.com