Skip to main content

Node runs my TypeScript now (mostly)

Type stripping is on by default in Node 22.18 and 24, so my scripts run as plain .ts files with a couple of rules and one tsconfig.

Updated 3 min readMarkdown
Node runs my TypeScript now (mostly)

Every small TypeScript script I've written for years has needed a helper to run it. ts-node first, then tsx, because it was quicker and didn't fight ESM. It always felt slightly ridiculous to install a package so I could run a forty-line file that renames some CSVs.

But as of July that's mostly over. Type stripping landed behind --experimental-strip-types in Node 22.6, was unflagged in Node 23.6 in January, and got backported to the LTS line in 22.18. On a current 22.x or on 24, this just works:

node scripts/rename-exports.ts

You get an ExperimentalWarning the first time, which I ignore for scripts. It's worth knowing what Node is doing, because it isn't type-checking anything. It swaps the type annotations for whitespace (so line and column numbers stay put and stack traces are right without source maps) and runs what's left. Which means only syntax that can be erased works. Enums, namespaces with runtime code, parameter properties like constructor(private db: Db) and old-style decorators all throw ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. There's --experimental-transform-types for those, but I'd rather change the code, and enums were the only ones I actually had:

// before
export enum Status { Draft = "draft", Live = "live" }

// after
export const Status = { Draft: "draft", Live: "live" } as const;
export type Status = (typeof Status)[keyof typeof Status];

Call sites stay as Status.Live. Imports caught me out twice, though. A type-only import has to say import type, or Node keeps the import and then falls over at runtime when there's nothing there. And you write the real file extension, ./read.ts, which TypeScript used to hate. allowImportingTsExtensions fixes that when you're not emitting, or rewriteRelativeImportExtensions (TS 5.7) if you also compile with tsc.

The tsconfig I use for scripts

TypeScript 5.8 added erasableSyntaxOnly, which makes the checker complain about exactly the things Node can't strip, so you find out in the editor instead of at runtime. With verbatimModuleSyntax forcing the import type habit, the pair catches nearly everything:

{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "strict": true,
    "skipLibCheck": true
  },
  "include": ["scripts/**/*.ts"]
}

A trimmed-down script from one of my side projects, with top-level await, built-in fetch and no dependencies at all:

// scripts/check-links.ts
import { readFile } from "node:fs/promises";
import type { Post } from "../lib/types.ts";

const posts: Post[] = JSON.parse(await readFile("data/posts.json", "utf8"));

for (const post of posts) {
  const res = await fetch(post.url, { method: "HEAD" });
  if (!res.ok) console.log(`${res.status}  ${post.slug}`);
}

Also worth knowing: require() of ES modules is unflagged in 22.12 and 20.19, as long as the module doesn't use top-level await. I had an old internal tool pinned to an old version of a library for exactly that reason, and bumping it just worked.

I haven't uninstalled tsx. Anything that runs in production still goes through a proper build with a type-check in CI, and Node doesn't read paths from tsconfig, so code importing @/lib/db needs a loader (or package.json imports like #lib/db, which I've started moving to). Stripping also never checks your types, so tsc --noEmit stays in CI. Node running the file only tells you it parses.

For scripts, seed files and one-off migrations, though, I just run the .ts file now. Not having to explain to a new joiner why they need three packages to run seed.ts is a small thing that I'm very happy about.


First published on my blog. Cover photo: Peter Shanks from Hobart, Australia, CC BY 2.0, via Wikimedia Commons (source).

About the author

  • Full stack engineer in London with 5+ years on production web platforms. I lead AI-assisted modernisation at Xanda: React and Go for the UK energy market, a government claims portal, and moving old Java and C++ systems onto the cloud. Outside work I run a small home server behind a Cloudflare Tunnel with a handful of public tools on it: Doorstep (rental alerts across Rightmove, OnTheMarket and OpenRent), SeenSooner (NHS A&E and planned-care waits), GridPulse (live GB grid carbon), Sift (LLM ticket triage that survives model failures) and AI Radar (a news digest written hourly by an n8n workflow). I write here about React and Next.js upgrades, Node, coding with AI agents, and the CVEs I've had to patch. Originals live at blogs.kishankanejiiya.tech.

No comments yet. Be the first to comment.