Writing Shell Scripts with TypeScript instead of Bash

bash/shell scripts are a common part of every developer’s life. There is no doubt in my mind that anything can be done in bash, but I am often surprised how just how surprised that I am at the behavior or limitations of the language and library of functions available. As some shell scripts started to get complex and where already in a TypeScript repository I started writing them in TypeScript instead of bash. Some benefits of TypeScript over shell scripts include type safety, better tooling, and improved maintainability. I found a couple tricks that have been enduring and I have started using in multiple places now.

a humorous image of duct tape around a computer with scripts on the screena humorous image of duct tape around a computer with scripts on the screen


The first trick is the shebang. I’ve been through a few iterations of this and surprising frustration due to node’s handling of ES Modules in different versions, but my current favorite shebang to make any TypeScript script executable is:

#!/usr/bin/env -S npx tsx

This makes it so that nothing needs installed as npx will handle it. To make it work, add the shebang as the first line of the file and make the file executable with chmod +x <filename>. Then you can just run the file like any other shell script.

Google’s zx

At some point I ran across zx that provides some wrappers around child_process and has some other handy functions for writing scripts.


Below is a full example that I use to move some files from a local computer to a nas:


#!/usr/bin/env -S npx tsx

import { config } from "dotenv"
import * as path from "path"
import { moveDirectories } from "./moveDirectories"

const dirname = path.dirname(new URL(import.meta.url).pathname)
config({ path: path.resolve(dirname, "../.env") })

const ENV_PAIRS = [
  { staging: "MEDIA_TV_STAGING", finished: "MEDIA_TV_FINISHED" },

for (const dirs of ENV_PAIRS) {
  if (process.env[dirs.staging] && process.env[dirs.finished]) {
    const stagingExpanded = expandVarsInPathString(process.env[dirs.staging])
    const finishedExpanded = expandVarsInPathString(process.env[dirs.finished])
    await moveDirectories(stagingExpanded, finishedExpanded)
  } else {
      `Environment variables for ${dirs.staging} and ${dirs.finished} not specified. Skipping!`

function expandVarsInPathString(path: string): string {
  return path.replace(/\$[a-z\d_]+/gi, function (match) {
    const sub = process.env[match.substring(1)]
    return sub || match


import { $, fs, echo, question } from "zx"

export async function moveDirectories(
  staging: string,
  finished: string
): Promise<void> {
  if (!(await fs.pathExists(staging))) {
    echo(`Skipping non-existent directory ${staging}!`)
  const directories = fs
    .readdirSync(staging, { withFileTypes: true })
    .filter((dirent) => dirent.isDirectory())
    .map((dirent) => dirent.name)

  if (directories.length > 0) {
    echo(`The following directories will be moved to ${finished}:`)
    for (const dir of directories) {
      echo(`- ${dir}`)
  } else {
    echo(`No directories found at ${staging}.`)

  const answer = await question(
    "Do you want to continue (answer with y or yes to continue)?"

  if (answer && answer.toLowerCase().startsWith("y")) {
    for (const dir of directories) {
      echo(`Moving ${dir} to ${finished}...`)
      await $`rsync -ah --progress --remove-source-files ${staging}/${dir} ${finished}`

      echo(`Done moving ${dir} to ${finished}.`)
      await $`find ${staging}/${dir} -type d -empty -delete`
    echo(`The listed directories have been moved to ${finished}.`)
  } else {
    echo(`No directories have been moved.`)

I’ve come to use this TypeScript shebang and zx on debian & macOS more and more. As soon as the complexity reaches anything that requires a couple functions I tend to find them more maintainable and easier to write than using bash. Hope you do too.

Up next Surprising Side Effects of Server Side Rendering in Next.js for SEO & Performance
Latest posts Writing Shell Scripts with TypeScript instead of Bash Surprising Side Effects of Server Side Rendering in Next.js for SEO & Performance When Empowering Employees to Take Risks, Isn’t Empowering (and Why That Needs to Change) Rationalizing Frequent Deployments for Product Managers and Software Engineers Now Write Right: 3 Best Practices for Writing to Innovate and Influence Write Right Now: How Engineers Can Innovate, Influence, and Lead in Business