I converted the entire Node side of this site to TypeScript. Not a partial job either, all of it: app.ts, the routers, the phish-report heuristics engine in lib/, the content build script, even the test file. The two things I left alone are the pm2 config, since pm2 needs to read it directly as plain JS, and the couple of browser-side scripts in public/javascripts, which run straight in the browser with no bundler in front of them and would need a completely different toolchain to compile.
The real motivation was lib/. That's where the phish-report tool's actual analysis logic lives: parsing untrusted email content, scoring URLs for typosquatting, checking SPF/DKIM/DMARC results. It's the one part of the site doing anything I'd call genuinely complex, and it was passing loosely-shaped objects between six or seven functions with nothing checking that the shapes actually lined up. That's exactly the kind of code where a type checker catches something a code review would miss.
Once that part was converted it seemed silly to leave the rest as an untyped island in a typed sea, so I kept going.
The approach
No bundler, no ts-node in production, nothing exotic. tsc compiles each .ts file to a .js file sitting right next to it, same filename, same directory. Every require() and import path in the app stays exactly what it was. The compiled .js is gitignored and gets rebuilt before start, test, and deploy. Deploy itself needed one real change: deploy.sh used to run npm install --omit=dev, but typescript is a devDependency, so now it does a full install, builds, then npm prune --omit=dev to drop the dev-only packages again before pm2 restarts. Same production node_modules as before, just built in one extra step.
Things that didn't go as expected
A few things actually surprised me, which is usually a sign a migration like this is worth writing down.
TypeScript 7 removed moduleResolution: node10. I reached for the old "node" setting out of habit and got a straight compile error telling me it's gone. This environment happens to have the new native TypeScript compiler, and it turns out the old Node resolution mode I've used for years just doesn't exist anymore. Switched to Node16, then later to the officially recommended NodeNext/Node16 pairing once I went looking for what the TypeScript team actually recommends for a Node 24 target.
export = versus export default actually matters here. app.ts does a plain require('./routes/phish-report') under the hood and expects the router object back directly. If that file uses export default router, the compiled output wraps it in a .default property instead of exporting it directly, and the route mount silently breaks. TypeScript's CommonJS-interop export assignment, export = router, is the one that matches what a plain require() actually expects.
Node's own built-in TypeScript support fought with my build. Recent Node versions can run .ts files directly without any compile step, and by default node --test will discover and try to execute test/smoke.test.ts on its own, completely separately from my compiled test/smoke.test.js. Since that raw source uses an extensionless import that only resolves under CommonJS, running it as native ESM failed immediately. I had to scope the test script to node --test test/*.js explicitly so it only ever touches the compiled output.
The type declarations didn't match the runtime. This is the one that actually would have shipped a real bug. @types/node had been installed at the latest available version, which described Node 26's API surface, while the site actually runs on Node 24 in CI and production. That gap means TypeScript could type-check code as valid because some newer Node API exists in the types, compile it clean, and then have that code crash at runtime on the actual Node 24 the server runs. Pinned @types/node to the 24.x line and added an engines field to package.json so that expectation is written down somewhere instead of just living in my head and the GitHub Actions config.
Making sure it actually works
I don't trust "it compiled" as proof of anything. I rebuilt and ran the full test suite against the real Node 24 binary specifically, not just whatever version happens to be on my machine, and separately posted a synthetic phishing email through the running server to confirm the heuristics still flag SPF/DKIM failures, typosquatting, and urgency language correctly after the rewrite. Everything matched what it did before.
Nothing about how the site behaves changed. That was the whole point. The type checker is just there now, quietly catching the next version of that @types/node mismatch before it ships.