$(...) and $? with a typed helper that runs another program, captures its output, and aborts on failure — the Bun twin of set -e.
Scripts rarely live alone — they run git, docker, ffmpeg. Bun's spawn APIs give you the exit code and stdout as typed values, in a way that can't be corrupted by shell quoting.
You give Bun.spawnSync a command as an array of strings — program first, then each argument. No shell parses it, so no quoting bugs and no injection. (Bun docs: child processes)
Bun.spawnSync(["git", "status", "--porcelain"]); // program, then its args
Bun.spawnSync(["sh", "-c", "echo ok"]); // only if you truly need a shell
Bun.spawnSync | Bun.spawn | |
|---|---|---|
| Style | blocking — your script waits | async — returns a Subprocess you can await |
| stdout | Buffer or undefined | ReadableStream — await proc.stdout.text() |
| State | exitCode, success immediately | await proc.exited → exit code |
| Use for | the 95% — daily scripts | parallel long-running jobs |
By default stdout is piped to you and stderr is inherited (passed straight to your terminal). To also capture stderr as data, pass stderr: "pipe".
const r = Bun.spawnSync(["git", "status", "--porcelain"], { stderr: "pipe" });
r.stdout.toString(); // captured stdout as a string
r.exitCode; // 0 = success — the "exit code" you used to read from $?
r.success; // true iff exitCode === 0
set -e twinThe single highest-value habit: make your script stop when a child fails, and hand the child's exit code onward so the caller sees failure too. This is set -e plus $?, in three lines:
function sh(cmd: string[]) {
const r = Bun.spawnSync(cmd, { stderr: "pipe" });
if (!r.success) {
process.stderr.write(r.stderr.toString()); // show what went wrong
process.exit(r.exitCode ?? 1); // propagate the real code
}
return r.stdout.toString(); // success: you get the output
}
const dirty = sh(["git", "status", "--porcelain"]);
if (dirty.trim()) console.error("uncommitted changes:"), console.log(dirty);
sh(["npm", "test"]); // anything after this only runs if tests passed
Chain calls sequentially and every later line is guarded — that's your cmd1 && cmd2 && cmd3.
Bun.spawnSync(["ls"], { cwd: "/tmp" }); // run somewhere else (like cd path)
Bun.spawnSync(["sh", "-c", "echo $MSG"], {
env: { ...process.env, MSG: "hi" }, // add env vars — merge process.env!
});
Bun.spawnSync(["sleep", "30"], { timeout: 500 }); // exitCode null on timeout
env replaces the environment entirely, so spread process.env in first. And beware: a command can't be found by name if you drop PATH.
| bash | Bun |
|---|---|
out=$(cmd) | const out = sh(["cmd", ...]) — already trimmed to a string |
$? | r.exitCode / r.success |
cmd && next | the sh() helper — next is unreachable after a failure |
set -e | same helper: it exits with the child's code |
cd dir && cmd | { cwd: "dir" } |
MYVAR=x cmd | { env: { ...process.env, MYVAR: "x" } } |
r.success (false) and r.exitCode (non-zero). Check them on every spawn — never assume success.sh() helper do on failure?process.exit(exitCode) — the script stops and the caller sees the real code, like set -e.set -eBuild run.ts around the sh() helper above (copy it in), then:
console.log to show their output — e.g. console.log(sh(["echo", "step one"])), console.log(sh(["echo", "step two"])), sh(["false"]), console.log(sh(["echo", "step three"])).false failure stopped the chain, and the exit code is 1.["false"] with something real you run daily, and confirm the exit code equals the child's: ./run.ts; echo "exit: $?"sh(["git", "status", "--porcelain"]) to warn if there are uncommitted changes before running a slow command.Check your work (immediate feedback):
./run.ts # prints "step one", "step two", then stops — "step three" is skipped
./run.ts; echo "exit: $?" # prints: exit: 1 — propagated the failure code
If the final echo $? printed 1, your script is honest about failure — exactly what CI and cron need, and exactly what bash gave you with set -e.
Primary source for this lesson: Bun Docs: Child Processes. The "Input stream" and "Exit handling" sections are the detail you'll want once these commands take arguments.
And yes — there's an even more bash-shaped way to write these commands: Bun Shell, the $ tag, with pipes and redirection built in. That's our next lesson, built on the exit-code discipline you just practised.