Replacing child_process with Bun Shell — Running cross-platform scripts in a type-safe way inside TypeScript
When you first use child_process.execSync, you let it slide. A one-liner build script, a few deployment commands. It works fine locally. But when the CI switches to a Windows runner or a teammate starts using a Windows environment, problems surface. cp -r stops working, pipe behavior differs, and scripts blow up because bash isn't in the path. Each time, you end up maintaining separate .sh and .bat files, or tacking on packages like cross-env, rimraf, and mkdirp.
Bun Shell approaches this problem from a different angle. Instead of wrapping an external shell (bash, PowerShell), it embeds a self-contained shell interpreter written in Rust directly inside the Bun runtime. This means the same commands behave identically on Windows, macOS, and Linux, and you can use them naturally inside TypeScript with Tagged Template Literal syntax. Variable interpolation is escaped by default, giving you built-in protection against OS command injection.
This article breaks down — with code — why Bun Shell is fundamentally different from child_process, which scenarios it's genuinely useful for, and where its limits lie. It's intended as a reference for teams already using Bun or considering a switch, who want to consolidate their build and deployment scripts.
Why Bun Shell, and Why Now
The Structural Limitations of child_process
child_process.exec and execSync are convenient. Internally, though, they fork a separate process — /bin/sh on Unix and cmd.exe on Windows — and hand off commands to it. Because the shell differs per platform, so does behavior, and return values come back as string | Buffer that you have to parse yourself.
import { execSync } from "child_process";
execSync("rm -rf dist && tsc && cp -r dist/ build/", { stdio: "inherit" });This single line fails on Windows because rm and cp simply don't exist there. Working around that by adding cross-env, rimraf, and mkdirp bloats devDependencies and requires you to learn the argument conventions and edge cases of each individual command.
Meeting the Direct TypeScript Execution Trend
Over the past few years, Node.js 22+ with --experimental-strip-types, Deno, and Bun have all converged on running TypeScript without a separate transpilation step. This trend naturally created demand for "build scripts in TypeScript too," and Bun Shell has settled into that role.
Bun Shell was first announced on the official blog in January 2024, and the Bun 1.1 release notes adopted it as the execution engine for package.json scripts alongside official Windows support. As of August 2026, enough real-world usage has accumulated that teams building on Bun can treat it as a legitimate option when consolidating build and deployment scripts in TypeScript.
A Different Architecture
The key point is that command interpretation, piping, redirection, and built-in execution of commands like cat and echo all happen inside the Bun process itself. External binaries like tsc, docker, and aws still fork a separate process, but because the shell interpretation layer is platform-agnostic, script behavior stays consistent.
Basic Syntax and API
It all starts with a single import { $ } from "bun". The Tagged Template Literal syntax makes the code read like a shell script.
import { $ } from "bun";
const output = await $`echo "Hello Bun"`.text();
const filename = "my file.txt";
await $`cat ${filename}`;
const count = await $`ls | wc -l`.text();
const { exitCode } = await $`nonexistent-cmd`.nothrow().quiet();
const pkg = await $`cat package.json`.json();
await $`node build.js`.env({ NODE_ENV: "production" });.nothrow() prevents an exception from being thrown when a command returns a non-zero exit code, and .quiet() suppresses stdout/stderr from flowing to the parent process. Combining the two lets you run a command that's allowed to fail silently and inspect only the exit code.
Choose an output format based on what you need:
| Method | Return Type |
|---|---|
.text() |
Promise<string> |
.json() |
Promise<unknown> |
.arrayBuffer() |
Promise<ArrayBuffer> |
.blob() |
Promise<Blob> |
.bytes() |
Promise<Uint8Array> |
Code for Real-World Scenarios
1. Replacing Existing child_process Scripts
Migration is usually a direct one-for-one substitution.
// Before: child_process
import { execSync } from "child_process";
execSync("tsc && cp -r dist/ build/", { stdio: "inherit" });
// After: Bun Shell
import { $ } from "bun";
await $`tsc && cp -r dist/ build/`;The reason the same cp -r works on Windows is that Bun Shell implements cp as its own built-in. File copying is handled by Bun's internal implementation on Windows too, so no additional utility is needed. tsc is still forked as an external binary, but the shell syntax itself is interpreted inside the interpreter, so the && operator behaves the same regardless of platform.
2. Parallel Build Tasks
You can run multiple commands concurrently with Promise.all. Because each $ call returns an independent Promise, parallel execution happens naturally.
import { $ } from "bun";
await Promise.all([
$`bun run build:client`,
$`bun run build:server`,
$`bun run generate:types`,
]);3. Docker Build + ECR Deployment Automation
A pattern you often encounter in deployment scripts. Variable interpolation is automatically escaped, so even if TAG contains special characters, the command won't break.
import { $ } from "bun";
const IMAGE = "my-app";
const TAG = process.env.GIT_SHA ?? "latest";
const REPO = "123456789.dkr.ecr.ap-northeast-2.amazonaws.com";
await $`aws ecr get-login-password --region ap-northeast-2 | docker login --username AWS --password-stdin ${REPO}`;
await $`docker build -t ${IMAGE}:${TAG} .`;
await $`docker tag ${IMAGE}:${TAG} ${REPO}/${IMAGE}:${TAG}`;
await $`docker push ${REPO}/${IMAGE}:${TAG}`;4. Conditional Error Handling — Pre-Deployment Check
A pattern that blocks deployment when uncommitted changes exist. git diff --exit-code returns exit code 1 if there are differences, so you suppress the exception with .nothrow(), hide the diff output with .quiet(), and inspect only the exit code.
import { $ } from "bun";
const { exitCode } = await $`git diff --exit-code`.nothrow().quiet();
if (exitCode !== 0) {
console.error("Uncommitted changes detected. Aborting deploy.");
process.exit(1);
}5. Globbing + Batch File Processing
Paired with Bun's native Glob, file-processing pipelines are fully self-contained in TypeScript. Glob is exposed as a class, so you instantiate it and call .scan().
import { $, Glob } from "bun";
const glob = new Glob("src/**/*.ts");
for await (const f of glob.scan(".")) {
await $`prettier --write ${f}`;
}6. Per-Script Environment Variable Overrides
$.env() creates a shell instance with specific environment variables overridden. This does not create a fully isolated environment — values like PATH and HOME are still inherited. It's appropriate for override use cases like "run the test script with only DATABASE_URL changed."
import { $ } from "bun";
const testShell = $.env({
...process.env,
NODE_ENV: "test",
DATABASE_URL: "postgres://localhost/test",
});
await testShell`bun test`;If you need actual process isolation, use a container or a separate shell session.
7. Per-Package Builds in a Monorepo
Using the --cwd option, you can execute scripts in each package of a monorepo.
import { $ } from "bun";
import { readdir } from "node:fs/promises";
const packages = await readdir("packages");
for (const pkg of packages) {
await $`bun run --cwd packages/${pkg} build`.nothrow();
}Trade-offs
Bun Shell vs. Alternative Tools
| Tool | Approach | Platform | Notes |
|---|---|---|---|
| Bun Shell | Built-in interpreter | Cross-platform | Bun-only, no additional install |
| zx (google/zx) | Wraps system bash/sh | Unix (+ limited Windows) | Mature ecosystem, Node.js support |
| child_process | OS shell fork/exec | Platform-dependent | Standard Node.js API, lacks type safety |
| execa | child_process wrapper | Platform-dependent | Promise support, better DX |
| shelljs | JS implementation of bash commands | Cross-platform | Aging library, slowing maintenance |
Advantages at a Glance
| Item | Description |
|---|---|
| Cross-platform | Same behavior on Windows, macOS, and Linux without bash |
| Type safety | Argument and output types handled explicitly inside TypeScript |
| Automatic escaping | Interpolated variable escaping defends against OS command injection |
| Low overhead (built-ins only) | Built-in commands like cat, echo, cp execute without process fork |
| JS object interop | Connect Blob, ArrayBuffer, Bun.file() as stdin/stdout |
| No extra dependencies | No separate npm packages needed; built into the Bun runtime |
| Parallel execution | Run multiple commands concurrently with Promise.all |
Limitations and Caveats
It's Bun-only. It doesn't work in a Node.js environment. It only makes sense if both your team and CI have adopted Bun.
It doesn't support every bash feature. Complex sed/awk pipelines or scenarios requiring full POSIX compatibility hit walls. Trying to port existing .sh scripts directly will surface some syntax and behavior differences. In those cases, mixing in Bun.spawn(["bash", "script.sh"]) to call the original shell file is a valid approach.
Interactive commands cannot be executed. Interactive commands requiring TTY input, like rustup or npm init, are not supported.
External binaries still incur a process fork. External executables like docker, aws, and tsc are not Bun built-ins, so fork/exec overhead still applies and they must be installed in the system PATH. The "low-overhead execution" characteristic applies exclusively to built-in commands.
The built-in command list expands with each release. The Shell page in the official Bun docs lists commands like cat, cd, cp, echo, exit, ls, mkdir, mv, pwd, rm, touch, and which. Commands like wc or find may not be built-ins depending on your Bun version, in which case they fall back to system binaries. Before relying on a command, test it locally and check the release notes for support status if needed.
Migration Decision Flow
Security: Automatic Escaping Isn't Everything
Bun Shell's automatic interpolation escaping is practical. That said, escaping doesn't eliminate every injection threat.
import { $ } from "bun";
const userInput = process.argv[2];
await $`echo ${userInput}`;
await $`echo ${$.raw(userInput)}`;$.raw bypasses escaping and inserts a string directly as shell syntax. A value passed through it can be interpreted as part of a command, redirection, or pipe, so you must never use it with externally supplied input. The only safe use case is strings that are already validated and controlled within the code itself — fixed arguments, values known at compile time, and so on. When handling user input, environment variables, or external API responses, always use regular interpolation.
Wrapping Up — A Checklist for When You Actually Start Migrating
The value of Bun Shell isn't just "use shell commands from TypeScript." It's the ability to manage build and deployment scripts inside the TypeScript type system, without separate .sh files or platform branches. That said, the problems you hit after deciding to adopt it tend to be on the infrastructure side, not the language side.
The first thing to address is installing Bun on your CI runner. For GitHub Actions, that means oven-sh/setup-bun; for GitLab or Jenkins, a custom image or curl -fsSL https://bun.sh/install | bash. Scripts that run fine locally dying in CI with "bun: command not found" is the first bottleneck and the most common failure point.
Second is taking inventory of your existing .sh scripts. Enumerate all shell calls scattered across package.json scripts, Makefile, .husky/, and .github/workflows/, then classify what each script uses: is the command a Bun Shell built-in or an external binary? Does it rely on POSIX-specific syntax (process substitution <(), set -o pipefail, arrays, etc.)? Scripts covered by built-ins with no special syntax are the easiest first candidates to migrate; scripts using POSIX-specific syntax should stay as Bun.spawn(["bash", ...]) candidates.
Third is auditing .raw usage. If, during migration, a moment comes where bypassing escaping seems necessary, stop and re-examine the input source right then. Any external input mixed in even slightly is a CVE waiting to happen.
Once you clear these three checkpoints, the rest is mostly a line-by-line porting exercise. If your team has been wanting to get rid of child_process, now is a reasonable time to start evaluating.
References
- Shell — Bun Official Docs — Full API reference for
$,.nothrow(),.quiet(),.env(), built-in command list, and more - The Bun Shell — Bun Blog (January 2024) — Initial Bun Shell announcement and design philosophy
- Bun 1.1 Release Notes — Official Windows support and adoption as the
package.json scriptsexecution engine - Bun Glob — Official Docs —
new Glob(pattern).scan()API usage - Bun.spawn — Official Docs — API for executing external processes (bash scripts, etc.)
- Node.js child_process — Official Docs — Platform-specific shell behavior for
exec,spawn, andexecSync