What we want to do
Run TypeScript files directly:
node index.ts
Use ES Modules and Top Level awaits:
import { transform } from "./transform.ts";
import { readFile } from "fs/promises";
const f = await readFile("./file");
await transform(f);
Run tests:
node --test
Restart the process when the source files change:
node --watch index.ts
Read .env files without dotenv.
Read command-line arguments.
How to do it
Typescript, ES Modules and Top Level awaits
Create a package.json that looks like this:
{
"type": "module",
"scripts": {
"test": "node --test",
"start": "node index.ts"
},
"devDependencies": {
"@types/node": "^24.0.14",
"typescript": "^5.8.3"
}
}
The important line above is "type": "module". It enables ES Modules by default, without having to use the .mts extension.
Create a tsconfig.json that looks like that:
{
"compilerOptions": {
"target": "esnext",
"module": "nodenext",
"rewriteRelativeImportExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}
The most important options here are:
target: "esnext", module: "nodenext": allows es modules and top-level awaitsrewriteRelativeImportExtensions: true: When a module is imported asabc.ts, the import is rewritten asabcd.jswhen compilingerasableSyntaxOnly: true: Only allow syntax that works when running the.tsfiles directly with node (see Limitations below)
These options not only make sure the code is compiled correctly, but also make autocompletion work as expected (for ex. auto-creating the import statements with the .ts extension, or automatically adding the type qualifier to the imports).
Limitations:
When running node index.ts, Node.js erases the type information. This means:
-
Type checking is not performed. You still need to run
tscor look at the errors in your editor. -
TypeScript features that generate code are not supported. The most common of them is enums.
Tests
Just create test files:
// operations.test.ts
import test from "node:test";
import { strictEqual } from "node:assert";
import { add } from "./operations";
test("addition", () => {
strictEqual(add(1, 2), 3);
});
.env files
import { loadEnvFile } from "node:process";
loadEnvFile();
An alternative way to do that is to use parseEnv, but it doesn’t merge the environment into process.env:
import { parseEnv } from "node:util";
import { readFile } from "node:fs/promises";
const env = await parseEnv(await readFile(".env", "utf-8"));
Command-Line arguments
import { parseArgs } from "node:util";
import { argv } from "process";
const {
values: { url, timeout },
} = parseArgs({
args: argv.slice(2),
options: {
url: { type: "string" },
timeout: { type: "string", default: "600" },
},
});
The function is pretty basic (it doesn’t support required arguments, doesn’t generate a --help command), but it’s largely good enough for small scripts.
Restart your server when the source files change
No setup required 🎉, just run:
node --watch index.ts