Schlez
2020

CLI Apps in TypeScript with `cmd-ts` (Part 1)

Using `cmd-ts` to easily build a type-safe TypeScript CLI app.Published .
🤌tl;dr

for a while now, I am using a custom module called cmd-ts, which is a type-first command line argument parser. Don't know what it means? I'll try to explain below! Anyway, give it a try, and let's make it better together.

cmd-ts provides the nicest UX and DXcmd-ts provides the nicest UX and DX


I write a lot of command line applications, both at work and for my own usage. Like many, my go-to toolkit for building them in Node.js was commander or similar packages. They're great, but after using structopt, the struct-based derive macro for the clap Rust crate, I felt something is missing.

Let's assume we're building a powerful cat clone. We want to read files, support reading from the standard input, and making HTTP calls. Check out the following code which implements it in a very simple fashion:

import fs from "fs";

fs.createReadStream(process.argv[2]).pipe(process.stdout);

We can use our cat clone like so:

$ ts-node ./cat.ts ./cat.ts
import fs from 'fs';

fs.createReadStream(process.argv[2]).pipe(process.stdout);

Amazing! It can only read a file, and we need to add the rest of the features, but the code is very nice. but... this doesn't scale.

  • We might want to have autogenerated --help
  • We might want to validate that we get a value in the file name
  • We might want to add boolean --flags or --option=value arguments

So we need a nicer way to parse the command line arguments the user provides us.

There are not a lot of big CLI apps that are using raw process.argv. Most of them use commander which is a high-level CLI parser, and some use yargs or a similar package. If you're familiar with Commander, you might find yourself writing something like:

import { Command } from "commander";
import fs from "fs";

new Command()
  .arguments("<path>")
  .action((path) => {
    fs.createReadStream(path).pipe(process.stdout);
  })
  .parse(process.argv);

Commander did satisfy our last requests, which is good:

  • We can have an autogenerated --help
  • It validates that we get a value for <path>
  • It is very simple to add --flags or --option=values

However, it does not provide us any way of type safety. TypeScript obviously can't identify what path is. It is typed as any. It is exactly the same as named --option=value and boolean --flags. You're in any land. This is not something we want but Commander doesn't let us declare types anywhere. Maybe with the template strings features of TS 4.1 the Commander team will write the description string parser in TypeScript and infer everything from the string.

Commander does validate for presence of value in <path>, and it looks like:

$ ts-node cat.ts
error: missing required argument 'path'

Now, consider if you want to validate more stuff, and not just the presence of a value. What if you want to check if the provided path exists? What if we want different parsing/validation behavior, or in other words — why do we treat all input as simple strings?

cmd-ts is a new command-line argument parser library that works kinda differently. It is influenced by Rust's structopt and provides a "type-first" approach to command line argument parsing:

  • Everything is type-safe
  • Types are both in runtime and compile-time, for maximum confidence
  • Parsing/validation are handled by the argument parser
  • Extremely easy unit testing and code sharing

Cloning the previous example

cmd-ts' syntax is very different from Commander's. Instead of using a builder pattern, cmd-ts favors building applications using simple function composition. This allows you to extract and share logic between applications, commands or modules — or even just to make your code shorter and more manageable. So, to replicate the exact behavior, which doesn't show cmd-ts' superpowers, will be the following:

import { command, positional, binary, run } from "cmd-ts";
import fs from "fs";

const cat = command({
  name: "cat",
  args: {
    path: positional(),
  },
  async handler({ path }) {
    fs.createReadStream(path).pipe(process.stdout);
  },
});

run(binary(cat), process.argv);

Let's break it down:

command is a function that creates a command-line application. These applications can be later composed using the subcommands function. It expects a name and an optional version to be printed with --help and --version.

In order to declare arguments (like we do with .arguments() in Commander), we provide the args object to command. Every key/value pair in args is an argument definition. Arguments can be parsed in various ways, and one of them is positional: taking a position-based argument from the user input.

Each argument is parsed into a type. We provide the string type (this is an object declared in cmd-ts, not to be confused with TypeScript's string type). These types can be consumed via a 3rd party or be easily built for your custom validation/parsing needs. We'll see an example in a bit.

The handler function receives all the arguments parsed by args. The key/value pairs are statically typed into handler - so you can destructure the value and be confident that you will get the type you expect. In our example, path is not explicitly typed, so it uses the default one — string — and if we provided a different type (like, number), TypeScript could stop us from making mistakes in compile time.

But again — this implementation doesn't show the amazing capabilities you can have by using cmd-ts types.

Reading an existing file

Let's try running our command, and try to print a non-existing file:

$ ts-node cat.ts unknown-file
events.js:291
      throw er; // Unhandled 'error' event
      ^

Error: ENOENT: no such file or directory, open 'unknown-file'
Emitted 'error' event on ReadStream instance at:
    at internal/fs/streams.js:136:12
    at FSReqCallback.oncomplete (fs.js:156:23) {
  errno: -2,
  code: 'ENOENT',
  syscall: 'open',
  path: 'unknown-file'
}

This is not a nice error at all. It happens because createReadStream creates a stream from a non-existing file, and when trying to read it we get an error event which we didn't handle. A common practice in Commander apps is to validate in your action, and therefore have a userland reporting mechanism.

cmd-ts pushes the validation and parsing into the "type", which is a part of the parsing process. So, instead of using the default string type, we can use another type. But which type should we use? cmd-ts comes with optional "battery packs" you can use in your project. One of them is the file system pack which exports the File type, which resolves into a path of an existing file. Fails to parse if file doesn't exist or the provided path is not a file.

The change itself is very simple to do — adding the File type to the positional argument definition:

import { command, positional, binary, run } from "cmd-ts";
import { File } from "cmd-ts/dist/cjs/batteries/fs";
import fs from "fs";

const cat = command({
  name: "cat",
  args: {
    path: positional({ type: File }),
  },
  async handler({ path }) {
    fs.createReadStream(path).pipe(process.stdout);
  },
});

run(binary(cat), process.argv);

Nothing really changed in our implementation detail (the handler function) but we do declare that we expect a file. Check out the following error message for a non-existing file:

$ ts-node cat.ts unknown-file
error: found 1 error

  cat unknown-file
      ^ Path doesn't exist


hint: for more information, try 'cat --help'

A similar error would be if we try to pass a directory to our application:

$ ts-node cat.ts /tmp
error: found 1 error

  cat /tmp
      ^ Provided path is not a file


hint: for more information, try 'cat --help'

Roll Your Own Validations

cmd-ts allows you to create custom types and validate user input yourself, abstracting it from the implementation. These types can be shared across different applications, modules and even internally in different commands. There is no reason the actual handling code should verify path existence, or that the user input is a valid URL, or that the user input matches a list of strings. It can all be baked into the parsing and provide great errors with context to whatever was wrong in their input.

Check out the implementation of the filesystem pack or the simple number type to see how easy is to implement it yourself.

The next part will be a step-by-step guide to build a custom type, which will make our code look even nicer and show how it affects testing and ergonomics.


cmd-ts has some more gems baked into it, so you should probably take a look at the docs. I personally don't use any other command line argument parser, and it's not only because my righteous bias — other team members at Wix are already using it to distribute great developer experience across the organization.

So you should definitely check it out, and contact me if you have any questions or issues.

Read more