← All articles
JAVASCRIPT Bun Shell and Script Runner: Replace Bash with TypeS... 2026-02-15 · 7 min read · bun · scripting · shell

Bun Shell and Script Runner: Replace Bash with TypeScript

JavaScript 2026-02-15 · 7 min read bun scripting shell automation typescript task-runner zx

Bun Shell and Script Runner: Replace Bash with TypeScript

Bun shell scripting logo

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:

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:

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.