Moving my site's browser code to TypeScript 7
Checking it takes a fraction of a second. The two bugs that turned up were found by a browser script, not the compiler.
This site is Rust on the server. Pages reach your browser as finished HTML, and only a thin layer of code runs there: the theme panel, the mobile menu, the project dialogs, blog pagination, the admin login and a few smaller bits. Until 25 September that layer was one JavaScript file of about a thousand lines, plus two small scripts I had written by hand and never put through any build at all. That day I moved all of it to TypeScript.
I wanted the newest release. TypeScript 7 is the compiler rewritten in Go, and Microsoft released it on 8 July. From the outside almost nothing changes. You still run npm install -D typescript, and the command is still tsc. The difference shows up in node_modules, where the tsc script hands the work to a native Go program.
I can't tell you much about the speed, though. A full check of my client code takes less than a fifth of a second, and on code this small there isn't a lot to speed up.
Here TypeScript only checks and doesn't write any files. esbuild still does the bundling, as it did before. So npm run build:js now runs tsc -p . first and stops if anything fails, and then esbuild writes the same three files under the same names. None of the HTML templates had to change, which kept the change small.
There is a catch I haven't fixed yet. The type check only runs when I rebuild the JavaScript. Nothing in my pre-push checks runs tsc, so a type error could sit in a commit until the next build catches it.
Alpine needed one extra step. The site's Content Security Policy only lets scripts run from the site's own files. Normal Alpine turns the text in attributes like @click into code at runtime, and that policy blocks it, so I use Alpine's CSP build instead. In that build the attributes can't hold real logic, not even sums, so it all lives in methods registered from the script. That's why my code is full of tiny helpers like isMenuOpen(). The CSP build also ships no type definitions. Its API is the same as regular Alpine's, so I borrowed those types with a small declaration file:
declare module '@alpinejs/csp' {
import Alpine from 'alpinejs';
export default Alpine;
}
The import inside it resolves to the @types/alpinejs package, and from then on every component gets checked against Alpine's published types. It isn't a perfect fit. Those types were written for an older Alpine than the one the site runs, and in a few places I had to add a cast, which is really just telling the checker to trust me.
Then came strict mode, with one extra setting switched on, noUncheckedIndexedAccess. It makes every lookup into an array or an object come back as "maybe undefined", so you have to say what happens when the thing is missing. The numbers that count up on the homepage used to read a regex match like this:
const end = parseInt(match[1], 10);
With the setting on, that line doesn't compile, because match[1] might not exist. Now it's parseInt(match[1] ?? '0', 10). A lot of the port was changes like that, and lookups into the theme list gained a ?. too. I don't think any of them was failing on the live site, since the keys mostly come from the same data they look up. The code just says now what it does when something is missing.
The change I think improved the code most was about two scripts talking to each other. You can pick a theme on this site, and your choice has to be painted before the page appears, or you see the default colours flash first. So a small script in the page head runs before anything shows. It holds the colour maths that nudges each accent colour until its text is readable on the background. The main bundle loads later and needs the same maths for the settings panel.
Before the port, the early script put two functions on window and the main bundle called them by name. Nothing checked that they existed or what they expected. Now there is one object, and its shape is written down in a shared file:
declare global {
interface Window {
hyperspace: HyperspaceApi;
hyperspaceRevealReady?: boolean;
}
}
Both scripts are checked against it, so if one side drifts from the other, tsc stops the build. The settings panel also stopped repeating the early script's colour writes and calls the shared functions instead. While I was in there, the two logout routines, which were identical line for line, became one. The early script also went from about 9 KB to under 3 KB. I'd like to take credit for that, but it's mostly because esbuild minifies it now and my hand-written version never was.
Then I had to check that nothing had changed. The colour maths got the closest look, because a slip there would mean text nobody can read. In Node I ran the old early script, taken from git, next to the new bundle and fed both every accent on every palette. They gave identical colours. After that, a script drove every component in headless Chromium: the theme panel, the scroll animations, the project dialog, the blog pages, the login form and the mobile menu. No console errors.
The same browser script, run against the old code, found two bugs, and neither came from the port. Both had been there since January, before the site went live.
The first was about this.$el. In Alpine, $el inside a method means the element that the calling event handler sits on, not the component. When you clicked the gear button to open the settings panel, the old code looked for the panel inside the button:
const panel = this.$el.querySelector('#settings-panel');
It found nothing, so focus never moved into the panel. The mobile menu did the same thing when it looked for its first link. With a mouse you would never notice. On a keyboard, the panel opened and focus stayed on the gear button, even though the panel tells screen readers it is a modal dialog. The fix is this.$root, which always means the component's own element.
The second was in the panel's Tab trap, the code that keeps focus inside the panel while it's open. It had the same lookup problem, and behind that sat another one. The trap wrapped focus around from the last focusable element, but that element lives in a section that stays hidden unless customise mode is on. Focus never landed on it, so Tab walked straight out of the panel instead of going back to the top. Now the trap only counts elements you can actually see, using checkVisibility().
The type checker could never have caught either of these. $el and $root are both plain HTML elements as far as the types go, and a hidden button has the same type as a visible one. What found them was a script working the real page in a browser. If you get around this site with a keyboard, the theme panel and the mobile menu now put you inside them when they open.