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 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.
--help
--flag
s or --option=value
argumentsSo 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:
--help
<path>
--flag
s or --option=value
sHowever, 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 --flag
s. 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
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:
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.
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'
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.