My project, hirantha.me, is a Next.js and TypeScript codebase. It has 110 test files, and none of them use Jest, Vitest or any other test framework. They are plain node:test files run by one npm script:
"qa": "node --test \"scripts/qa/*.test.mjs\" \"scripts/qa/kit/*.test.mjs\""
The only way that works is one small file, scripts/qa/lib/ts-hooks.mjs. This post explains what is in it, why each part exists, and what the approach cannot do.
The problem: Node will not run this TypeScript
The build of Node 22 I use (v22.22.1) has no type stripping. A .ts file with a type annotation fails to run, and passing --experimental-strip-types does not help. It stops with ERR_NO_TYPESCRIPT. I checked both on a one-line file before writing this.
Even if it did strip types, the sources would not load. They were written for Next.js and its bundler, so they:
- import siblings without an extension (
from "./facts"), including folders that resolve toindex.ts - use the
@/alias forsrc/ - import
server-only, a marker package that throws if it is loaded outside a React server build - contain JSX
Node's ESM loader accepts none of these. The usual answer is a test framework with its own transform pipeline. I wanted to see how little it took to avoid one.
The tool: module.registerHooks
Node 22 has module.registerHooks. It registers synchronous resolve and load hooks in the same thread. That is different from the older register(), which runs hooks in a separate thread. With the synchronous version, a test file can import the hook module and the hooks are active straight away. There is no --import flag and no loader process.
The file has two hooks. resolve decides which URL a specifier means. load decides what source text that URL gives Node.
Resolve: extensions, alias and the stub
const EXTENSIONS = [".ts", ".tsx", "/index.ts", "/index.tsx"];
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === "server-only") return { url: "data:text/javascript,export {}", shortCircuit: true };
let base = null;
if (specifier.startsWith("@/")) base = path.join(ROOT, "src", specifier.slice(2));
else if ((specifier.startsWith("./") || specifier.startsWith("../")) && context.parentURL?.startsWith("file:")) {
base = path.resolve(path.dirname(fileURLToPath(context.parentURL)), specifier);
}
if (base && !/\.\w+$/.test(path.basename(base))) {
for (const ext of EXTENSIONS) {
if (existsSync(base + ext)) return { url: pathToFileURL(base + ext).href, shortCircuit: true };
}
}
// ...
Three things happen here.
server-only becomes a data: URL whose whole source is export {}. The package exists to fail loudly in the wrong place, and a test is deliberately the wrong place, so I replace it with an empty module.
The @/ alias is a string replacement onto src/. Relative imports are resolved against the importing file's directory.
If the resulting path has no extension, I try .ts, .tsx, /index.ts and /index.tsx in that order and take the first file that exists. The check !/\.\w+$/.test(...) stops the hook from touching specifiers that already have an extension, such as ./data.json.
Load: compile with the repo's own TypeScript
load(url, context, nextLoad) {
if (/\.tsx?$/.test(url) && url.startsWith(SRC_URL)) {
const file = fileURLToPath(url);
const out = ts.transpileModule(readFileSync(file, "utf8"), {
compilerOptions: {
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ES2022,
jsx: ts.JsxEmit.ReactJSX,
},
fileName: file,
});
return { format: "module", source: out.outputText, shortCircuit: true };
}
return nextLoad(url, context);
},
The typescript package is already a dev dependency, so nothing new is installed. The file loads it from the repo root with createRequire(path.join(ROOT, "package.json"))("typescript"). That makes the test compiler the same version the project uses.
transpileModule only strips types and rewrites JSX. It does not type-check, and it does not look at other files. That is why it is fast. jsx: ReactJSX is the automatic runtime, so a component compiles without importing React, and a test can render it with react-dom/server.
Only files under src/ are compiled. Everything else, including node_modules, goes to nextLoad untouched.
How a test uses it
A test file imports the module for its side effect, then imports the code to test:
const { src } = await import("../lib/ts-hooks.mjs");
const T = await src("lib/tools/kit/b23-sl-travel/expressway-toll-calculator-sri-lanka.ts");
src(rel) is a one-line helper: it dynamically imports a path relative to src/. The import has to be dynamic and awaited after the hooks are registered. A static import at the top of the test file would be resolved before the hooks exist.
Here is a real call to the expressway toll code, run through the hooks:
T.tollFee("south", "kottawa", "pinnaduwa", "1")
// { ok: true, fee: 500, from: { id: 'kottawa', ... }, to: { id: 'pinnaduwa', ... }, category: '1', network: 'south' }
T.tollFee("south", "kottawa", "kottawa", "1")
// { ok: false, reason: 'The entry and the exit are the same interchange.' }
The toll test file, b23-sl-travel.test.mjs, has 16 tests. It passes in about one second on my machine. Some of the tests compare values with the 17 December 2025 gazette table. Others check properties that must hold for any table. For example, every pair of interchanges has either a fee in all three vehicle categories or in none, and a category never costs less than the one below it. None of that needs a framework. It needs assert.equal, loops and a loader that works.
The bug that taught me the hook has to stay narrow
The third part of resolve is a workaround I added on 2026-11-01, in the same commit that moved the project to Next 16.4.3. next/link require()s @swc/helpers. In version 0.5.23, the module-sync export condition points require() at the package's ESM build. Loading ESM through require while these hooks were running failed with a reading 'exports' error.
The CommonJS build is the same code, so the hook now rewrites the URL:
const r = nextResolve(specifier, context);
const esm = r.url.match(/^(.*\/node_modules\/@swc\/helpers\/)esm\/(.+)\.js$/);
if (esm && existsSync(fileURLToPath(`${esm[1]}cjs/${esm[2]}.cjs`)))
return { ...r, url: `${esm[1]}cjs/${esm[2]}.cjs`, format: "commonjs" };
return r;
I do not know the root cause inside Node. It is specific to that package version, and this rewrite fixed it. If you copy this approach, expect a patch like that now and then. It is a line in a file you own.
Why not a test framework
Mostly because the thing I test is not the part a framework helps with. The tests mostly call pure functions and assert on the result. A test runner is already built into Node, with node --test, describe and it, and file globbing. The only missing piece was loading TypeScript, and that is the file above.
What it costs
-
No type checking.
transpileModulehappily compiles code with type errors. Type errors are caught by the normaltscor Next build, not by the tests. -
Hand-written resolution. The hook handles
@/and extensionless relative imports. It does not readtsconfig.jsonpaths, so a second alias would need a second line. Importing fromsrc/with a bare package-style name would not work either. -
Only
src/is compiled. A.tsfile anywhere else in the repo is not handled. - No mocking, coverage or watch mode. I do not have them. If a test needs a fake, it passes one in or sets up state itself. For code that touches the network, that is the main limit: these tests are for logic that runs without a server, and the header of the file says as much ("No server and no network are needed").
-
Node-version coupling. This relies on
module.registerHooks, which exists in the Node 22 line I run. When the Node I use has stable type stripping, most of this file could go. Even then, extensionless imports and the alias would still need the resolve hook. -
The stub hides a real rule. With
server-onlyreplaced, a test can import server code that client code must never import. Only the real build catches that mistake.
What I would reuse
Three ideas carry over to any codebase, whatever test runner you use:
- Synchronous
registerHooksmakes a loader something a test file can opt into by importing it. - Stub marker packages like
server-onlyat resolution time with adata:URL, instead of editing the sources. - Compile with the version of the compiler the project already has, and turn off everything that needs more than one file at a time.
If you test a TypeScript project, do you run it through a framework, a build step, or something closer to this? And what made you pick it?
This article was written with the help of AI. The code, numbers and sources come from my own project and were checked against it before publishing.
Top comments (0)