Add CLI Scripts to Your TypeScript/Node Project with TSX (No, the Other TSX)

Think about a typical development feedback loop on your software project when you’re editing code. After you hit save, what process are you interacting with? A hypothetical web project might have several:

  • Hot-reloading dev server
  • Unit test runner
  • Browser test framework
  • Storybook

While the above facets cover the bulk of the development work on my current project, there’s another I’m going to write about today: command-line scripts. (also known as “Command-line Interface”, or “CLI” scripts)

Friction

Apart from the typical node dist/index.js entry point of a persistent backend API server, it’s common for a web app to have a handful of other executable entry points. Perhaps there’s a background worker process, or a nightly job, or a database migration that runs during deployment. While these are technically CLI scripts, they’re probably compiled and bundled. Adding and running one likely comes with enough friction that you don’t find yourself reaching for them very often.

In an interpreted language1, it shouldn’t be hard to access an internal piece of your application and call right into it. But TypeScript isn’t quite an interpreted language, and the ecosystem’s dominant runtime can’t execute it natively2.

A tool called ts-node attempts to reconcile this, compiling TypeScript to JavaScript just-in-time, but I’ve had mixed results with it on past projects. I recently found an alternative.

TSX

The unfortunately-named3 tsx serves a similar role to ts-node, but it seems to just work without any extra configuration.

Here’s an example from my current project:


#!/usr/bin/env tsx
import { deleteAll, getPrismaSingleton } from "database";
import { loadDummyData } from "../src/backend/dummy-data";

const prisma = getPrismaSingleton();

async function go() {
  console.log("Wiping Database and Populating With Dummy Data");
  await deleteAll(prisma);
  await loadDummyData(prisma);
}

go();

For comparison, ts-node chokes on the first import statement:


ts-node scripts/load-dummy-data.ts
(node:59652) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
(Use `node --trace-warnings ...` to show where the warning was created)
/Users/jrr/foo/app/scripts/load-dummy-data.ts:38
import { deleteAll, getPrismaSingleton } from "database";
^^^^^^

SyntaxError: Cannot use import statement outside a module

I’m sure my project’s module configuration could be amended to make ts-node work, but based on past experience I expect touching that to break something else. Now that I have tsx, I don’t even have to try.

More Uses

I’ve found TypeScript CLI scripts to be particularly useful for:

  • Prototyping use of a library or service (e.g. client libraries for S3 or Postmark)
  • Operational access to data (exporting/loading)
  • Running a bespoke report
  • Gnarly data migrations
  • Testing external integrations (“What would our PDF service do if we sent it X?”)

The Value of One-off CLI Scripts

We’ve got a whole bunch of these scripts, accessible behind a short tab-completable ./do-the-thing.ts4. Many of them were written to serve the immediate need of a developer cranking on a feature, but grew into something useful to keep around.

Next time you find yourself looking for a place to run some temporary exploratory code, stop before you add that throwaway endpoint/route/component/test! Consider dropping it in a CLI script instead.

Does your project’s language let you easily write one-off scripts that call into arbitrary application code? Do you prefer to work in a REPL? Let me know in the comments!


Footnotes

1 Even when working in a compiled language with rigid project structure, I still like to have access to business logic from a developer-facing command-line tool. This often means deliberately keeping all important logic in shared libraries, where it can be consumed by multiple entry points.

2 Node.js can’t natively execute TypeScript, but Deno can!

3 Namespace Collision! The acronym “tsx” (“TypeScript Execute”) is a great name, in a vacuum, but unfortunately it’s already a thing in TypeScript. This is going to be awkward until one of the projects renames or fades away.

4 After installing tsx in your project, you can invoke it like pnpm tsx do-the-thing.ts. But if you put tsx on your executable path (e.g. with direnv), add a shebang line, and chmod+x the file, you can ./do-the-thing.ts instead.

Conversation

Join the conversation

Your email address will not be published. Required fields are marked *