You already know JavaScript. So replacing bash is mostly about learning where things live in Bun — how scripts run, how to read the parts bash gave you with $1, $?, and >&2.
Bun runs .ts (and .js) files directly — no config, no build step, no separate TypeScript install. Bun transpiles on the fly with its native transpiler.
bun run script.ts // explicit — preferred
bun script.ts // "naked" — identical
Top-level await is allowed, so a script can read files or hit an HTTP endpoint without wrapping everything in an async function — the way promises let you write. If you run the whole thing through bun run -, Bun even reads code from stdin. (Bun docs: CLI run)
Bash scripts are convenient because you can ./greet.sh them. Bun scripts can too — two steps, identical to what you do in bash:
# first line of the file:
#!/usr/bin/env bun
# then, once:
chmod +x script.ts
Note the extension matters: Bun treats a .sh file as a Bun Shell script. For JavaScript/TypeScript, keep .ts (or .js). Verified on Bun 1.4.1: shebang + chmod +x + ./script.ts works. (Bun docs: core runtime)
$1 replacementWhere bash gives you $0, $1, $@, Bun gives you Bun.argv — a plain array (an alias of process.argv). (Bun docs: utils — Bun.argv)
// bun run greet.ts Ada Lovelace
Bun.argv[0] // path to the bun binary
Bun.argv[1] // path to the script
Bun.argv[2] // "Ada" <- arguments start here
Bun.argv[3] // "Lovelace"
const args = Bun.argv.slice(2); // ["Ada", "Lovelace"]
$? and >&2 replacementSchedulers and the caller judge your script by its exit code: 0 means success, anything else means failure. Where bash writes usage: ... >&2 and exit 1, write:
console.error("usage: greet.ts <name>"); // stderr, keeps stdout clean
process.exit(1); // nonzero = failure
Prefer process.exitCode = 1 when you still have work in flight; process.exit(1) stops immediately.
| bash | Bun |
|---|---|
$1, $2 | Bun.argv[2], Bun.argv[3] — or destructure Bun.argv.slice(2) |
$@ (all args) | Bun.argv.slice(2) |
echo ... >&2 | console.error(...) |
exit 1 / $? | process.exit(1) / process.exitCode = 1 |
"hi $name" | `hi ${name}` |
#!/usr/bin/env bash | #!/usr/bin/env bun |
The portability payoff: the bash version needs bash installed and its [[ ]] / >&2 syntax differs in sh. The Bun version runs identically with one binary on macOS, Linux, and Windows.
main.ts?bun run main.ts (or the naked bun main.ts) runs TypeScript directly. tsc only type-checks; it doesn't execute.Bun.argv, where do your arguments start?./script.ts executable directly?#!/usr/bin/env bun shebang as line one, then chmod +x script.ts. The file must keep a .ts/.js extension, not .sh.Here is the bash you use daily. Your job: rebuild it in greet.ts using only what this lesson taught.
#!/usr/bin/env bash
name=$1
if [[ -z "$name" ]]; then
echo "usage: $0 <name>" >&2
exit 1
fi
echo "hello, $name"
greet.ts with the Bun shebang, reading the name from Bun.argv, using a template literal for the greeting, console.error for the usage message, and an exit code of 1 when the name is missing.chmod +x greet.ts, then run ./greet.ts Ada and ./greet.ts (no args).--shout flag after the name and uppercase the greeting when present.Check your work (immediate feedback):
./greet.ts Ada # prints: hello, Ada exit 0
./greet.ts # prints usage to stderr exit 1
bash -c './greet.ts Ada' # prove it works from bash too
If your no-arg run returned exit code 1 and printed to stderr, you've built a script that schedulers, CI, and humans can trust — running it on macOS or Windows would behave the same.
Your primary source for this lesson is the official page you should skim now: Bun Docs: Core Runtime. It frames the engine behind everything, and its sibling pages (CLI run, utils) back the exact claims above.
Also file away the mapping you just practised — it's the entire migration mindset: find the bash habit, locate its Bun twin, keep the contract (exit codes, streams) identical.