[One Package Per Day] Why We Use zx to Replace Bash Scripts
By hientd, at: July 7, 2025, 11:12 a.m.
Estimated Reading Time: __READING_TIME__ minutes
![[One Package Per Day] Why We Use zx to Replace Bash Scripts](/media/filer_public_thumbnails/filer_public/58/14/58144416-2dc4-4c49-88e7-d41103ead551/zx_-_a_tool_for_writing_better_scripts.png__1500x900_crop_subsampling-2_upscale.png)
![[One Package Per Day] Why We Use zx to Replace Bash Scripts](/media/filer_public_thumbnails/filer_public/58/14/58144416-2dc4-4c49-88e7-d41103ead551/zx_-_a_tool_for_writing_better_scripts.png__400x240_crop_subsampling-2_upscale.png)
If you’ve ever stared at a Bash script and thought, “There’s got to be a better way,” welcome to the club.
At Glinteco, we maintain a lot of internal automation, dev setup scripts, deployment flows, backups, Docker orchestration helpers. For a while, we defaulted to Bash. But as scripts got more complex, we started feeling the limits: quoting hell, poor error handling, lack of structure. Enter zx, a Node.js-based tool that makes writing shell scripts actually enjoyable.
Let’s walk through why we’re now using zx for most of our scripting needs.
What is zx?
Created by the team at Google, zx is a library that lets you write shell scripts in JavaScript (or TypeScript). Think of it as Node.js with superpowers like awaitable shell commands, built-in utilities like fs, os, and even globs.
Instead of:
#!/bin/bash
echo "Hello, $1"
You write:
#!/usr/bin/env zx
console.log(`Hello, ${process.argv[2]}`);
And suddenly your scripts are readable, testable, and maintainable.
Why We Ditched Bash for zx
1. Readable and Familiar Syntax
Most of our team is already fluent in JavaScript and TypeScript. With zx, we’re no longer fighting syntax oddities like:
if [[ -z "$VAR" ]]; then ...
Now we write:
if (!process.env.VAR) { ... }
2. Async/Await for Free
Ever tried to parallelize Bash processes with &, wait for them, and handle failures cleanly? We have. It sucked.
With zx, we do:
await Promise.all([
$`command1`,
$`command2`
]);
Boom. Easy concurrency, built-in.
3. Safer and Cleaner
With Bash, one unescaped space or forgotten quote can nuke your system. In zx, the `$``
string interpolation safely escapes arguments by default:
const branch = 'main';
await $`git checkout ${branch}`;
No more worrying about injection or weird characters.
4. Standard Library Goodness
You get:
-
fs, path, os modules
-
chalk, globby, yaml, etc.
-
Top-level await
Want to read a config and use it? Easy:
import fs from 'fs/promises';
const config = JSON.parse(await fs.readFile('./config.json', 'utf8'));
5. Cross-Platform Friendly
Bash is not always Bash. (Looking at you, macOS vs Linux vs Git Bash on Windows.)
With Node + zx, your scripts run the same everywhere Node runs. And that’s a big deal when onboarding devs or automating CI/CD pipelines.
Real Use Case: Our Dev Setup Script
Before:
#!/bin/bash
echo "Setting up..."
cd backend || exit 1
pip install -r requirements.txt
cd ../frontend || exit 1
npm install
After:
#!/usr/bin/env zx
await $`cd backend && pip install -r requirements.txt`
await $`cd ../frontend && npm install`
console.log(' Setup complete!');
We even added checks like missing Python or Node versions and threw user-friendly errors, all easier in JS.
Downsides?
Let’s be honest, there are a few trade-offs.
-
You need Node.js installed.
-
Performance is slightly slower than Bash for very tiny scripts.
-
You can’t run it where Node isn’t allowed (e.g., strict CI containers).
But in 95% of our use cases, the benefits far outweigh the drawbacks.
Final Thoughts
We still use Bash for tiny one-liners. But for anything over 10 lines, zx wins (hands down). It’s readable, powerful, and lets us write scripts without feeling like we’re sacrificing our sanity.
If you’re scripting a lot and your team is already comfortable with JavaScript or TypeScript, try zx. You’ll probably never go back.