Bun Shell and Script Runner: Replace Bash with TypeScript
Bun Shell and Script Runner: Replace Bash with TypeScript
Bash scripts are the duct tape of software engineering. They work until they don't -- and they stop working the moment you need to handle spaces in filenames, parse JSON, or run the same script on macOS and Linux without subtle breakage. The alternative has traditionally been reaching for Python or a Node.js script with child_process, both of which add enough friction that most developers just stick with bash and hope for the best.
Bun changes this equation. Its built-in shell API (Bun.$) gives you a tagged template literal that runs shell commands with proper cross-platform support, JavaScript-native error handling, and seamless integration with the rest of the Bun runtime. You can pipe commands, redirect output, use environment variables, and compose shell operations with async/await -- all in TypeScript with zero additional dependencies.
Why Not Just Use Bash?
Bash is fine for simple one-liners. But scripting beyond a few lines exposes real problems:
- No cross-platform support: Bash scripts break on Windows. macOS ships a decade-old bash (3.2) with missing features. Even between Linux distributions, behavior of common tools (
sed,grep,find) can differ. - Error handling is fragile:
set -edoesn't catch errors in pipelines.set -o pipefailhelps but introduces its own edge cases. Undefined variables silently expand to empty strings unless you useset -u, which then crashes on${var:-default}patterns. - String handling is dangerous: Unquoted variables undergo word splitting and glob expansion. This causes silent data corruption and security vulnerabilities.
- No data structures: Need a map? An array of objects? You're parsing text with
awkand hoping the format doesn't change.
Bun's shell API solves all of these while keeping the ergonomics that make shell scripting appealing in the first place.
The Bun.$ Shell API
The core API is a tagged template literal that executes shell commands:
import { $ } from "bun";
// Simple command
await $`echo "Hello from Bun shell"`;
// Capture output as text
const result = await $`ls -la`.text();
console.log(result);
// Get output as JSON
const pkg = await $`cat package.json`.json();
console.log(pkg.name);
// Get output as a Buffer
const binary = await $`cat image.png`.arrayBuffer();
Variable Interpolation (Safe by Default)
Unlike bash, interpolated values are escaped automatically. No more injection vulnerabilities from unsanitized input:
const filename = "file with spaces & special (chars).txt";
// This is safe -- Bun escapes the variable properly
await $`cat ${filename}`;
// In bash, this would break without careful quoting:
// cat $filename <-- splits on spaces, expands &
You can also interpolate arrays, and each element becomes a separate argument:
const files = ["src/index.ts", "src/utils.ts", "src/types.ts"];
await $`prettier --write ${files}`;
// Equivalent to: prettier --write src/index.ts src/utils.ts src/types.ts
Piping and Redirection
Bun's shell supports standard pipe and redirect syntax:
// Pipe between commands
await $`cat access.log | grep "POST" | wc -l`;
// Redirect to file
await $`echo "new config" > config.txt`;
// Append to file
await $`echo "another line" >> config.txt`;
// Pipe into Bun's own processing
const lines = await $`git log --oneline -20`.text();
const commits = lines.trim().split("\n").map(line => {
const [hash, ...messageParts] = line.split(" ");
return { hash, message: messageParts.join(" ") };
});
Error Handling
By default, Bun.$ throws on non-zero exit codes. This is a massive improvement over bash's silent failures:
try {
await $`git push origin main`;
} catch (err) {
console.error(`Push failed with exit code ${err.exitCode}`);
console.error(`stderr: ${err.stderr.toString()}`);
}
If you want to handle non-zero exits without exceptions (like bash's default behavior), use .nothrow():
const result = await $`grep "pattern" file.txt`.nothrow();
if (result.exitCode === 0) {
console.log("Found:", result.stdout.toString());
} else {
console.log("Pattern not found");
}
Environment Variables
// Set environment variables for a command
await $`deploy.sh`.env({
NODE_ENV: "production",
API_KEY: process.env.API_KEY,
});
// Or use the shell syntax
await $`NODE_ENV=production deploy.sh`;
Working Directory
await $`ls -la`.cwd("/tmp");
// Useful for monorepo scripts
const packages = ["packages/core", "packages/ui", "packages/cli"];
for (const pkg of packages) {
console.log(`Building ${pkg}...`);
await $`bun run build`.cwd(pkg);
}
Replacing Common Bash Patterns
Build Script
A typical build.sh rewritten in TypeScript:
#!/usr/bin/env bun
import { $ } from "bun";
// Clean
await $`rm -rf dist`;
// Type check
console.log("Type checking...");
await $`bunx tsc --noEmit`;
// Build
console.log("Building...");
await $`bun build src/index.ts --outdir dist --target node`;
// Copy assets
await $`cp -r public/* dist/`;
// Report
const size = await $`du -sh dist`.text();
console.log(`Build complete: ${size.trim()}`);
Make it executable with chmod +x build.ts and run with bun build.ts -- or just add it to package.json scripts.
CI/CD Pipeline Script
#!/usr/bin/env bun
import { $ } from "bun";
const branch = (await $`git branch --show-current`.text()).trim();
const isMain = branch === "main";
console.log(`Running CI on branch: ${branch}`);
// Always run these
await $`bun install --frozen-lockfile`;
await $`bun run lint`;
await $`bun test`;
// Only on main
if (isMain) {
await $`bun run build`;
const version = (await $`cat package.json`.json()).version;
console.log(`Deploying version ${version}...`);
await $`bun run deploy`;
}
File Processing
#!/usr/bin/env bun
import { $ } from "bun";
import { readdir } from "node:fs/promises";
// Find all large files and compress them
const files = await readdir("./uploads");
for (const file of files) {
const path = `./uploads/${file}`;
const stat = await Bun.file(path).size;
if (stat > 1_000_000) { // > 1MB
console.log(`Compressing ${file} (${(stat / 1_000_000).toFixed(1)}MB)`);
await $`gzip -k ${path}`;
}
}
Comparison with Alternatives
| Feature | Bun.$ | zx (Google) | tsx + child_process | bash |
|---|---|---|---|---|
| Language | TypeScript | TypeScript | TypeScript | Bash |
| Dependencies | None (built-in) | zx package + Node.js | tsx + Node.js | None |
| Install time | 0 (comes with Bun) | ~5s | ~3s | 0 |
| Startup time | ~5ms | ~150ms | ~200ms | ~2ms |
| Cross-platform | Yes | Mostly | Yes (code only) | No |
| Variable escaping | Automatic | Automatic | Manual | Manual |
| JSON handling | Native | Via Node.js | Via Node.js | jq required |
| Error handling | try/catch | try/catch | try/catch | set -e (fragile) |
| Pipe syntax | Shell-native | Shell-native | Manual | Shell-native |
| npm ecosystem | Full access | Full access | Full access | None |
Bun.$ vs zx
Google's zx was the pioneer of "shell scripting in JavaScript." It provides a similar tagged template literal API and inspired Bun's approach. Key differences:
- zx requires Node.js + npm install: It's an additional dependency. Bun's shell is built in.
- Startup overhead: zx scripts take 150-200ms to start due to Node.js boot time. Bun scripts start in single-digit milliseconds. For scripts that run frequently (git hooks, watch mode triggers), this adds up.
- zx has more built-in helpers:
cd(),question(),spinner(),chalkintegration. Bun's shell is more minimal but you can import any package. - zx uses system shell: It delegates to
/bin/bashorcmd.exe. Bun's shell is its own cross-platform implementation, so behavior is consistent across operating systems.
Bun.$ vs tsx
tsx lets you run TypeScript files directly with Node.js. But you're still using child_process.exec for shell commands, which means:
// tsx approach -- verbose and manual
import { execSync } from "child_process";
const output = execSync("git status --porcelain", { encoding: "utf-8" });
// Bun approach -- clean and type-safe
import { $ } from "bun";
const output = await $`git status --porcelain`.text();
tsx doesn't give you shell integration. It just runs TypeScript. You're still writing Node.js code with all its shell interaction limitations.
Bun as a Script Runner
Beyond the shell API, Bun is an excellent script runner for package.json scripts:
{
"scripts": {
"dev": "bun --watch src/index.ts",
"build": "bun build src/index.ts --outdir dist",
"test": "bun test",
"lint": "bunx biome check .",
"db:migrate": "bun run scripts/migrate.ts",
"db:seed": "bun run scripts/seed.ts"
}
}
Running Scripts Directly
Bun runs .ts, .tsx, .js, and .jsx files directly -- no compilation step, no tsconfig requirement:
# Run any TypeScript file directly
bun run scripts/deploy.ts
bun run scripts/generate-api-docs.ts
# Or make it executable
chmod +x scripts/deploy.ts
./scripts/deploy.ts # with #!/usr/bin/env bun shebang
Watch Mode
Bun's built-in watch mode reruns your script on file changes:
bun --watch scripts/dev-server.ts
This eliminates the need for nodemon or ts-node-dev in many development workflows.
Practical Tips
Use shebangs for executable scripts: Add #!/usr/bin/env bun as the first line, then chmod +x. This makes scripts runnable as ./script.ts rather than bun run script.ts.
Combine shell and TypeScript strengths: Use Bun's shell for process execution and piping, but use TypeScript for logic, data transformation, and error handling. Don't try to do everything in shell template literals.
Prefer .text() and .json() over raw output: The raw output of Bun.$ is a Buffer. Calling .text() gives you a string, .json() parses it. These are cleaner than manual buffer handling.
Use .quiet() for noisy commands: By default, Bun's shell inherits stdout/stderr. If you're capturing output and don't want it printed, use .quiet():
const result = await $`npm pack --json`.quiet().json();
Structure larger scripts as proper TypeScript: For scripts over ~50 lines, use functions, types, and imports just like you would in application code. The fact that it's a "script" doesn't mean it shouldn't be well-structured.
The Bottom Line
Bun's shell API hits a sweet spot that didn't previously exist: the convenience of bash one-liners with the safety and composability of TypeScript. For scripts that outgrow bash but don't warrant a full application framework, Bun.$ is the most ergonomic option available. Zero dependencies, instant startup, automatic escaping, cross-platform behavior, and direct access to the entire npm ecosystem -- all in a single runtime you probably already have installed.
If you're writing a new automation script today, write it in TypeScript with Bun. You'll thank yourself the first time you need to add error handling, parse JSON, or run it on a different operating system.