Daniele IrsutiDaniele IrsutiFrontend engineer

There are no more excuses for technical debt

How we moved a 13-year-old frontend to TypeScript: targeted casts, an Oxlint ratchet in CI and an agent for the grunt work. Builds from 2 min to 15 s.

When I joined the company several years ago and saw the code in my Visual Studio Code, my first reaction was to stare wide-eyed.

In front of me was code that bore the cost of many hands, many minds and, above all, many years. One git blame showed 10 years, and at least 3 more have passed since I got here. Legacy? This was a fossil.

Add to that the fact that I worked on the frontend, and those years weigh a lot: it had been through the Flux philosophy with jQuery, the first React experiments with Redux, without and then with TypeScript.

From day one, my goal and my vision was to escape this bottomless technical debt. Meeting after meeting followed, trying to contain the problem. We tried to strangle the old code, but it was always there: you couldn't throw it away, it always came back.

Much of it was the result of choices somewhat far from community standards, and that was a thorn in our side from the very start.

There was no linting, no proper TypeScript configuration for types and no tests of any kind - one of the biggest problems if you wanted to go through a manual refactor, component by component.

It took us a good few months just to bring linting back: inheriting such an old codebase, full of outdated or dead libraries, the first step was to update or remove those libraries and restore the long-awaited linting, which until then had been held hostage by a few old libraries only supported by dated versions of Node.

Once we managed that, it was time to head for TypeScript, and that's where the real trouble began.

Over-engineering, and generic components that had become the central hub of every section of the webapp, were a huge problem: fixing a type also meant updating the component's logic, and at that point we were at a crossroads: update the logic, or update the type? Either way there were a lot of components, and we had to make these changes incrementally so the project would still build.

This step turned out to be nearly impossible on individual goodwill alone: we build a product, and this work would have eaten time and budget that could probably have been better spent. So we quickly lost our illusions: we simply enabled strict typechecking at the editor level only and left everything else as it was.
At first we relied on a file we updated by hand whenever we fixed types; then that practice, partly because it was clunky (even though there was a script) and partly because nobody kept proper watch over it, happily went down the drain.

A lot of water has flowed under the bridge since then, but we still had pretty much the same technical debt as when I joined, with the difference that we had gradually introduced tests. These proved invaluable, especially once AI made its way into our processes.

Taking advantage of that, I thought of dusting off the now-forgotten and partly dismantled incremental typecheck, looking at it from a different angle.

In the first attempt, the idea was to fix either the type or the behaviour, but then I changed my mind: why do it right away? Meanwhile, thanks to the wave of change we are bringing to our systems, the old generic components are gradually falling out of use, so it would be better to at least make sure new code was born typed.

So the new idea involved some new steps: what if, instead of fixing the behaviour, I just used… casts? Some of you may be horrified at this point, but bear with me.

First, I looked for a suitable codemod. Tools like ts-migrate exist, but they silence errors with @ts-ignore or @ts-expect-error: they switch off the whole line, including any errors that will show up in the future. We wanted the opposite: keep the components' contracts unchanged, because changing them would have cascading effects, and isolate each error in a targeted cast on a single expression, leaving the typecheck active everywhere else. A codemod that, for each tsc error, picks the right expression and the right type doesn't exist: that's why I handed it to an agent.

Using casts, I would still have those somewhat so-so components, with the only safety net being a few tests that use them. So fine, let's use casts.

A cast, admittedly, is debt, but it's explicit debt: it's visible, searchable and measurable. A type error ignored by the pipeline and visible only in your own editor, on the other hand, is seen by no one.

In the first iteration, I instructed an agent to add the casts without making any change to the code: its constraint was to make no changes other than type-level ones.

With this change, typechecking was finally introduced into our pipelines.

We had quite a lot of code full of casts, and the idea at this point was to enforce an absolute ban on casts as a rule. It may sound contradictory: why cast everything and then ban casting? I used Oxlint, because it lets you exclude the files you want exempt from this rule through a single property.

So the plan was this: freeze the current situation, fix types and behaviours, remove files from the list. We went for a “ratchet” mechanism.

So we introduced a job that checks that linting passes and that files on that list, once modified and therefore free of casts, are actually removed from it. Otherwise: the pipeline fails.

Running a single terminal command is enough to automatically remove the fixed files from the Oxlint list.

Today there are 422 files on the list, but from here on that number can only go down. It looks scary, but many of them are legacy files headed for deprecation: it's just a matter of time.

Here is the idea behind the ratchet mechanism:

// tools/cast-ratchet/generate.mjs (simplified version)
const original = readFileSync(CONFIG, 'utf8');

// 1. Temporarily strip the exceptions and ask oxlint who still has casts
writeFileSync(CONFIG, stripOverrides(original));
let report;
try {
  const { stdout } = spawnSync(OXLINT, ['-f', 'json'], { encoding: 'utf8' });
  report = JSON.parse(stdout);
} finally {
  writeFileSync(CONFIG, original); // always restore
}

const dirty = report.diagnostics
  .filter((d) => d.code === 'typescript(consistent-type-assertions)')
  .map((d) => d.filename);

// 2. Compare with the committed list
const committed = readOverrides(original);
const stale = committed.filter((f) => !dirty.includes(f)); // cleaned-up files
const missing = dirty.filter((f) => !committed.includes(f)); // new files with casts

if (process.argv.includes('--check')) {
  process.exit(stale.length || missing.length ? 1 : 0);
}
writeFileSync(CONFIG, writeOverrides(original, dirty));
// .oxlintrc.json
{
  "rules": {
    "typescript/consistent-type-assertions": [
      "error",
      { "assertionStyle": "never" }
    ]
  },
  // ===== BEGIN CAST RATCHET (generated — do not edit manually) =====
  "overrides": [
    {
      "files": [
        "src/components/SearchList/index.tsx",
        "src/components/GenericForm/utils.ts"
        // ...N more files
      ],
      "rules": { "typescript/consistent-type-assertions": "off" }
    }
  ]
  // ===== END CAST RATCHET =====
}
// package.json
"scripts": {
  "ratchet:casts": "node tools/cast-ratchet/generate.mjs",
  "ratchet:casts:check": "node tools/cast-ratchet/generate.mjs --check"
}
## .gitlab-ci.yml (or equivalent)
lint:casts:
  script:
    - yarn ratchet:casts:check

Agents weren't used only on this occasion, though.

In our long-term vision of technological progress, one of the main goals was to switch bundlers, moving from webpack 5 to Vite 8 - no small step.

There were some obstacles, however.

The first: Vite only parses JSX syntax in files with the .jsx (or .tsx) extension, while our legacy components used .js. Instead of bending the bundler configuration, I preferred to tidy things up by renaming the components from .js to .jsx.

For this I had initially thought of a codemod, but it didn't cover this case (specifically: finding the .js files by checking their content, renaming them with git mv so they wouldn't lose their history, and finally updating the imports).
So, since the codemod wasn't quite the right "fit", I wrote (together with the agent) a script to carry out this procedure - then I had the agent run it and told it to open the relevant MRs.

The second: Vite serves source code as native ESM, and we still had CommonJS modules lying around (require/module.exports). In this case a codemod did exist: I personally used cjstoesm to convert them all.

As before, I handed the execution of the plan to the agent, which was instructed to change the syntax.

Since review was done both by humans and by agents, we split the work into small merge requests.

By clearing these small hurdles, we managed to move from webpack 5 to Vite 8 and cut build jobs from about 2 minutes to about… 15 seconds.

Working on such an old repository would have required mechanical work that was practically impossible to finish; it's something we had been trying to do for years. Instead, this was the intense, collective work of 3 people and an agent: me, with the technical vision of what needed doing, 2 human reviewers, and the AI, which did practically all of the grunt work, cheaply.

There are still many improvements ahead of us, but I'm finally confident, and aware, that they can be done.

To wrap up, I'd like to ask: if 13 years of technical debt can be tackled in a few weeks, what excuses do we have left to keep piling it up?

Daniele Irsuti

Written by Daniele Irsuti

Frontend engineer in Pescara, Sicilian. Passionate about design, psychology and writing.

Did you enjoy this post?

Buy me a coffee

Previous post

The second screen

Want to ask me something?

Come on, let's talk.