Porting a site's JavaScript to TypeScript 7, and the two bugs the compiler did not find
A strict type check caught nothing that mattered. Running the same browser test against the old build did.
Contents
Why move at all
The client side of this site is small. One file of Alpine.js components, about 1,075 lines, bundled by esbuild. A render-blocking script that paints the saved theme before first paint. Eighteen lines that rewrite UTC timestamps on the admin pages. It worked, and nothing in it was on fire.
TypeScript 7 is out, at 7.0.2 as I write this: the release where the compiler is rewritten in Go, which the TypeScript team puts at around ten times faster. That was the prompt. The reason was different: the theme code does colour maths that has to agree to the last digit between two files, and I wanted a compiler watching the shapes that flow between them.
The setup, briefly
The site runs the CSP build of Alpine, because the Content Security Policy has no unsafe-eval. Neither Alpine nor its CSP build ships type definitions. @types/alpinejs does, at 3.13.11, behind the Alpine 3.17 the site runs, and a four-line module declaration lends those types to the CSP package:
// The CSP build ships no types of its own. Its API is Alpine's, so borrow
// @types/alpinejs.
declare module '@alpinejs/csp' {
import Alpine from 'alpinejs';
export default Alpine;
}
tsc runs in strict mode with noUncheckedIndexedAccess, and npm run build:js refuses to bundle until it passes. esbuild still does the bundling and writes the same three files it always did, so no template changed.
What the compiler found
Two things, both mine and both from the port rather than the old code. A curried factory for the two "are you sure?" delete buttons lost the this type that Alpine.data infers, so this.$root did not exist as far as the compiler knew. And an array of tuples widened to string[][], so a destructured element could be undefined. Both took a minute.
noUncheckedIndexedAccess made me write a guard wherever the code indexed the theme table with a key from localStorage. The migration code already validated those keys, so the guards changed nothing at runtime. They do make a stale key impossible to forget next time.
That is the honest tally. Strict types found no bug that users had. The next two sections were more interesting.
Proving nothing changed
The render-blocking script fits accents to palettes: fourteen accents, ten palettes, and a bisection on HSL lightness until each pair clears WCAG AA contrast. The panel script calls the same function after every change. If the port moved one colour by one step, the accent would shift after first paint.
So before trusting the port I ran the old file from git and the new bundle side by side in Node, with the DOM stubbed out, and compared every fitted colour:
for (const theme of Object.values(data.themes)) {
for (const accent of Object.values(data.accents)) {
const a = JSON.stringify(old.win.hyperspaceAccent(accent, theme.base.nebula));
const b = JSON.stringify(neu.win.hyperspace.fitAccent(accent, theme.base.nebula));
if (a !== b) console.log('DIFF', theme.name, a, b);
}
}
140 accent pairings and 40 status colours, 180 comparisons, 0 differences. That is the check I would want from anyone else porting colour code.
The two bugs that were already there
Then a headless Chromium script drove every component: the count-up figures, scroll reveal, theme switching, the saved theme painting on reload, the project dialog, the mobile menu. Two readings looked wrong. Opening the settings panel left focus on the gear button. Opening the mobile menu left focus on the hamburger.
My first guess was that the port had broken them. So I swapped the old bundle back in and ran the same script. Same result. Both bugs had been live all along.
The cause is one line in each component:
// Before: $el is the element the handler sits on, the gear button.
this.$el.querySelector('#settings-panel')
// After: $root is the element carrying x-data, which holds the panel.
this.$root.querySelector('#settings-panel')
Inside an Alpine event handler, $el is the element the handler is written on, not the component. The button holds no panel, the lookup returned null, and the code that should have moved focus skipped quietly. Nothing threw, so nothing showed up in the console.
The settings panel had a second problem behind the first. Its focus trap wraps Tab from the last focusable element back to the first. The last element in the DOM sits in a section that x-show hides unless customise mode is on, so focus never landed on it, the wrap never fired, and Tab walked out of the panel into the page behind. The fix filters the list to elements the browser actually renders:
const focusables = [...panel.querySelectorAll<HTMLElement>(FOCUSABLE)]
.filter((el) => el.checkVisibility());
With both fixes in, focus lands inside each panel as it opens, and forty presses of Tab stay inside the settings panel.
What I take from it
A type checker answers "can this value have this shape". Neither bug was a shape problem. querySelector returning null is a perfectly typed outcome, and the old code's if (panel) guard, the very check strict mode demands, is what kept the failure silent.
What found them was a test that exercised behaviour, run twice: once against the new code and once against the old. Running it against the old build was the step that turned a regression hunt into a bug report. I will keep doing that with every port.
The port was still worth doing. fouc.js went from 8.9 KB to 2.8 KB, two duplicated logout routines became one, and the colour code now lives once and is shared instead of copied. The focus fixes were the part I did not plan for.