A couple of years ago I wrote about writing shell scripts with TypeScript instead of bash. The core argument still holds: once a script grows past a screenful, TypeScript beats bash on type safety, tooling, and maintainability. But the best trick in that post — the shebang — is now obsolete, and it's obsolete in the best possible way.

This was my favorite shebang at the time:
#!/usr/bin/env -S npx tsx
It worked, but it papered over a real problem: Node couldn't run TypeScript. Every execution paid for npx to resolve tsx, and tsx to transform the file, before a single line of your script ran. On a fresh machine it even hit the network.
Node can run TypeScript now. The shebang collapses to:
#!/usr/bin/env node
That's it. Save the file as script.mts, chmod +x it, and run it. No tsx, no npx, no build step, no tsconfig.json required. This works out of the box on Node 22.18+ and everything newer, and since Node 20 went end-of-life in April 2026, that is every supported release line: 22, 24, and 26.
Node 22.6.0 shipped --experimental-strip-types in August 2024, which does exactly what it says: before executing a .ts file, Node strips the type annotations and runs the remaining JavaScript. Node 23.6.0 turned it on by default in January 2025, and Node 22.18.0 backported the default to the LTS line that July. The experimental warning is gone too.
.mts, not .tsOne tip that saves real frustration: name your scripts .mts rather than .ts.
A .ts file's module system depends on the nearest package.json and its type field. Shell scripts tend to live in places like ~/bin where there is no package.json, and even when there is one, you don't want your script's behavior changing because of it. A .mts file is unambiguously an ES module, always, everywhere. In the original post I mentioned "surprising frustration due to node's handling of ES Modules in different versions" — .mts ends that entire category of problem.
Type stripping is not compilation. Node replaces type annotations with whitespace and runs what's left. That only works for syntax that has zero runtime effect — what TypeScript calls erasable syntax. A handful of older TypeScript features generate runtime code, and Node will refuse to run them, failing with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX:
enum (creates an object at runtime)namespace with runtime valuesconstructor(private x: number) (implies a field assignment)import x = require("pkg") (creates a binding)One more, once a script grows a second file: a type has to be imported with import type, and the import needs its .mts extension. Node strips what it knows is a type, and without the type keyword it doesn't know.
At least the errors tell you exactly what's wrong:
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript namespace declaration is not supported in strip-only mode
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript parameter property is not supported in strip-only mode
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript import equals declaration is not supported in strip-only mode
A type-only declare namespace D { const y: number } runs fine, since nothing of it survives to runtime. It is the value-producing forms that Node rejects.
If you're wondering why Node can't just handle these: it would have to rewrite your code rather than erase parts of it, which means code generation and sourcemaps. Node had that mode behind a flag, --experimental-transform-types, but dropped it in Node 26. The built-in path deliberately guarantees that a TypeScript file is just a JavaScript file plus removable annotations. If you need enums, that's what tsx is still for.
You won't miss these features in scripts, but you probably want your editor to yell about them before Node does. TypeScript 5.8 added the erasableSyntaxOnly compiler option for exactly this:
{
"compilerOptions": {
"erasableSyntaxOnly": true,
"module": "nodenext",
"target": "esnext",
"types": ["node"],
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"strict": true
}
}
With that, enum E { A } fails at the editor rather than at runtime:
error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
Don't skip the "types": ["node"] line. Node doesn't care, since it never type-checks, but since TypeScript 6 your editor won't load @types/node without it and every process comes back as Cannot find name 'process'.
There's no per-file pragma for compiler options. The closest thing is /// <reference types="node" /> under the shebang, which at least fixes the types problem without a config file. Deno has header-comment directives for its linter and formatter, but even Deno keeps compiler options in a config file. I sure wish one of these tools would let a standalone script carry its own settings in a header comment.
One more thing worth knowing: stripping means no type checking at runtime. Node removes the types; it doesn't validate them. Your editor still checks as you write, and you can run tsc --noEmit if you want, but this is the same deal tsx gave you — nothing lost.
The real limitation: the moment your script needs an npm package, you're back to needing a package.json and an install step. Node has no way to declare dependencies inline in a single file — Deno and Bun can import packages directly, and Python's uv has inline script metadata (PEP 723) for exactly this. I'd love to see Node grow an answer here.
But this matters less than it used to, because Node's standard library now covers most of what my scripts used to pull in packages for: built-in fetch, env file loading, glob, terminal colors, even a test runner. The one worth calling out is util.parseArgs — proper flag parsing in the standard library was a big add, and it eliminates the most common reason scripts reached for a dependency in the first place. A surprising number of scripts now need zero dependencies, which means a single self-contained executable file.
Zero runtime dependencies, that is. If you want type checking you still need typescript and @types/node installed somewhere, and those have to come from a package.json. Node runs the file with nothing installed; your editor telling you that fs.readdirSync returns Dirent[] does not. I keep that package.json in the scripts directory with devDependencies only, so the script itself stays importable-from-nothing.
The original post's example moved media files to a NAS. It was two files and used dotenv and zx. Here it is as a single self-contained .mts with zero dependencies:
#!/usr/bin/env node
import { spawn } from "node:child_process"
import * as fs from "node:fs"
import * as path from "node:path"
import { createInterface } from "node:readline/promises"
const envPath = path.resolve(import.meta.dirname, "../.env")
if (fs.existsSync(envPath)) {
process.loadEnvFile(envPath)
} else {
console.error(`No .env file at ${envPath}. Reading environment only.`)
}
const ENV_PAIRS = [
{ staging: "MEDIA_TV_STAGING", finished: "MEDIA_TV_FINISHED" },
{ staging: "MEDIA_MOVIES_STAGING", finished: "MEDIA_MOVIES_FINISHED" },
{
staging: "MEDIA_AUDIOBOOKS_STAGING",
finished: "MEDIA_AUDIOBOOKS_FINISHED",
},
]
for (const dirs of ENV_PAIRS) {
const staging = process.env[dirs.staging]
const finished = process.env[dirs.finished]
if (staging && finished) {
await moveDirectories(
expandVarsInPathString(staging),
expandVarsInPathString(finished)
)
} else {
console.error(
`Environment variables for ${dirs.staging} and ${dirs.finished} not specified. Skipping!`
)
}
}
/** Moves every immediate subdirectory of staging into finished, after confirming. */
async function moveDirectories(
staging: string,
finished: string
): Promise<void> {
if (!fs.existsSync(staging)) {
console.log(`Skipping non-existent directory ${staging}!`)
return
}
const directories = fs
.readdirSync(staging, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory())
.map((dirent) => dirent.name)
if (directories.length === 0) {
console.log(`No directories found at ${staging}.`)
return
}
console.log(`The following directories will be moved to ${finished}:`)
for (const dir of directories) {
console.log(`- ${dir}`)
}
const answer = await question(
"Do you want to continue (answer with y or yes to continue)? "
)
if (!answer.toLowerCase().startsWith("y")) {
console.log(`No directories have been moved.`)
return
}
for (const dir of directories) {
console.log(`Moving ${dir} to ${finished}...`)
await run("rsync", [
"-ah",
"--progress",
"--remove-source-files",
path.join(staging, dir),
finished,
])
console.log(`Done moving ${dir} to ${finished}.`)
await run("find", [
path.join(staging, dir),
"-type",
"d",
"-empty",
"-delete",
])
}
console.log(`The listed directories have been moved to ${finished}.`)
}
/** Prompts on stdin, returning an empty string if stdin closes without input. */
async function question(prompt: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stdout })
try {
return await Promise.race([
rl.question(prompt),
new Promise<string>((resolve) => rl.once("close", () => resolve(""))),
])
} finally {
rl.close()
}
}
/** Runs a command with inherited stdio, rejecting on a non-zero exit code. */
function run(command: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: "inherit" })
child.on("error", reject)
child.on("close", (code) => {
if (code === 0) {
resolve()
} else {
reject(new Error(`${command} exited with code ${code}`))
}
})
})
}
/** Substitutes `$VAR` references in a path with their environment values. */
function expandVarsInPathString(pathString: string): string {
return pathString.replace(/\$[a-z\d_]+/gi, function (match) {
const sub = process.env[match.substring(1)]
return sub || match
})
}
chmod +x move-media-to-nas.mts and run it. One file, nothing installed at runtime. Top-level await just works because .mts is always an ES module.
Two things bit me converting it.
The first was __dirname. The old ESM workaround is path.dirname(new URL(import.meta.url).pathname), which is what the original post's version of this script used, and it is wrong: a file URL is percent-encoded, so a script living in ~/my scripts/bin resolves its .env to /Users/me/my%20scripts/.env and dies with:
Error: ENOENT: no such file or directory, open '/Users/me/my%20scripts/.env'
import.meta.dirname (Node 20.11+) is a real decoded filesystem path and has no such problem. Same for import.meta.filename. If you are copying ESM boilerplate written before late 2023, this is in it.
The second was process.loadEnvFile. It is not a drop-in for dotenv's config(): dotenv shrugs at a missing file, loadEnvFile throws ENOENT and takes the script down with an unhandled stack trace. Hence the existsSync guard above. Going the other way, loadEnvFile with no argument reads .env from the current working directory, which for a script you invoke from anywhere is almost never what you want, so pass the path explicitly.
.nvmrcThe shebang is #!/usr/bin/env node, which means the script runs on whatever Node is first in PATH. On an older one it does not say "upgrade Node". This script dies with Unknown file extension ".mts", and a script with no imports dies with a syntax error at the first type annotation. Neither tells you the fix. Worth being explicit about the floor:
22
The real floor is 22.18.0, and it comes from type stripping being on by default, which landed in 23.6.0 and was backported to 22.18.0 (2025-07-31). Everything else the script uses is older: import.meta.dirname is 20.11.0, process.loadEnvFile is 20.12.0, node:readline/promises is 17.0.0. The floor goes in package.json, where it can express a minimum:
"engines": { "node": ">=22.18.0" }
Note that .nvmrc holds 22, not 22.18.0. nvm reads the file as an exact version, not a minimum, so 22.18.0 makes nvm use demand precisely that build even when you already have 22.23.2 installed. 22 accepts any 22.x on the machine, and engines keeps the precise bound.
And I'm still hoping the TC39 type annotations proposal ultimately moves forward — types in JavaScript, natively, everywhere. The erasable subset you're writing for Node today is exactly what it proposes. Here's the part I like most. Writing to the erasable subset isn't a workaround for Node. It's the same subset the TC39 proposal would make plain JavaScript, so a script you write today should be closest to whatever JavaScript ends up being.
If you've got shell scripts still paying the npx tsx tax — or worse, still fighting bash — try the new shebang and let me know how it goes.