CLI Apps in TypeScript with `cmd-ts` (Part 1)
Tags: typescript, javascript, cli, node
Published: 2021-04-06 17:24:52

---
title: CLI Apps in TypeScript with `cmd-ts` (Part 1)
published: true
date: 2020-11-26 00:00:00 UTC
tags: typescript, javascript, cli, nodejs
canonical_url: https://gal.hagever.com/posts/type-safe-cli-apps-in-typescript-with-cmd-ts-part-1/
---

 **tl;dr** — for a while now, I am using a custom module called [`cmd-ts`](https://github.com/Schniz/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](https://gal.hagever.com/static/227fcaae22e619bafa7b3e0c063590f3/c0255/nice-errors.png)

* * *

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`](https://npm.im/commander) or similar packages. They’re great, but after using [`structopt`](https://docs.rs/structopt), the struct-based derive macro for the [`clap`](https://docs.rs/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:

```typescript
import fs from "fs";

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

We can use our `cat` clone like so:

```typescript
$ 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 `--flag`s 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:

```typescript
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 `--flag`s or `--option=value`s

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 `--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:

```typescript
$ 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?_

## Enter [`cmd-ts`](https://github.com/Schniz/cmd-ts)[<svg aria-hidden="true" focusable="false" height="16" version="1.1" viewbox="0 0 16 16" width="16"><path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path></svg>](#enter-cmd-ts)

[`cmd-ts`](https://github.com/Schniz/cmd-ts) is a new command-line argument parser library that works kinda differently. It is influenced by Rust’s [`structopt`](http://docs.rs/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[<svg aria-hidden="true" focusable="false" height="16" version="1.1" viewbox="0 0 16 16" width="16"><path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path></svg>](#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:

```typescript
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`](https://cmd-ts.hagever.com/parsers/command.html) is a function that creates a command-line application. These applications can be later composed using the [`subcommands`](https://cmd-ts.hagever.com/parsers/subcommands.html) 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`](https://cmd-ts.hagever.com/parsers/positionals.html): taking a position-based argument from the user input.

Each argument is parsed into a type. We provide the [`string`](https://cmd-ts.hagever.com/included_types.html#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`](https://cmd-ts.hagever.com/included_types.html#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[<svg aria-hidden="true" focusable="false" height="16" version="1.1" viewbox="0 0 16 16" width="16"><path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path></svg>](#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”](https://cmd-ts.hagever.com/batteries.html) you can use in your project. One of them is the [file system pack](https://cmd-ts.hagever.com/batteries_file_system.html) 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:

```typescript
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:

```typescript
$ 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:

```typescript
$ 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[<svg aria-hidden="true" focusable="false" height="16" version="1.1" viewbox="0 0 16 16" width="16"><path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path></svg>](#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`](https://github.com/Schniz/cmd-ts/blob/master/src/batteries/fs.ts) or the simple [`number type`](https://github.com/Schniz/cmd-ts/blob/master/src/types.ts) 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](https://cmd-ts.hagever.com). 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.