Introduction

cmd-ts is a type-driven command line argument parser written in TypeScript. Let's break it down:

A command line argument parser written in TypeScript

Much like commander and similar Node.js tools, the goal of cmd-ts is to provide your users a superior experience while using your app from the terminal.

cmd-ts is built with TypeScript and tries to bring soundness and ease of use to CLI apps. It is fully typed and allows custom types as CLI arguments. More on that on the next paragraph.

cmd-ts API is built with small, composable "parsers" that are easily extensible

cmd-ts has a wonderful error output, which preserves the parsing context, allowing the users to know what they've mistyped and where, instead of playing a guessing game

Type-driven command line argument parser

cmd-ts is essentially an adapter between the user's shell and the code. For some reason, most command line argument parsers only accept strings as arguments, and provide no typechecking that the value makes sense in the context of your app:

  • Some arguments may be a number; so providing a string should result in an error
  • Some arguments may be an integer; so providing a float should result in an error
  • Some arguments may be readable files; so providing a missing path should result in an error

These types of concerns are mostly implemented in userland right now. cmd-ts has a different way of thinking about it using the Type construct, which provides both static (TypeScript) and runtime typechecking. The power of Type lets us have a strongly-typed commands that provide us autocomplete for our implementation and confidence in our codebase, while providing an awesome experience for the users, when they provide a wrong argument. More on that on the Custom Types guide

Getting Started

Install the package using npm:

npm install --save cmd-ts

or if you use Yarn:

yarn add cmd-ts

Using cmd-ts

All the interesting stuff is exported from the main module. Try writing the following app:

import { command, run, string, positional } from 'cmd-ts';

const app = command({
  name: 'my-first-app',
  args: {
    someArg: positional({ type: string, displayName: 'some arg' }),
  },
  handler: ({ someArg }) => {
    console.log({ someArg });
  },
});

run(app, process.argv.slice(2));

This app is taking one string positional argument and prints it to the screen. Read more about the different parsers and combinators in Parsers and Combinators.

Note: string is one type that comes included in cmd-ts. There are more of these bundled in the included types guide. You can define your own types using the custom types guide

Included Types

string

A simple string => string type. Useful for option and positional arguments

boolean

A simple boolean => boolean type. Useful for flag

number

A string => number type. Checks that the input is indeed a number or fails with a descriptive error message.

optional(type)

Takes a type and makes it nullable by providing a default value of undefined

array(type)

Takes a type and turns it into an array of type, useful for multioption and multiflag.

union([types])

Tries to decode the types provided until it succeeds, or throws all the errors combined. There's an optional configuration to this function:

  • combineErrors: function that takes a list of strings (the error messages) and returns a string which is the combined error message. The default value for it is to join with a newline: xs => xs.join("\n").

oneOf(["string1", "string2", ...])

Takes a closed set of string values to decode from. An exact enum.

Custom Types

Not all command line arguments are strings. You sometimes want integers, UUIDs, file paths, directories, globs...

Note: this section describes the ReadStream type, implemented in ./example/test-types.ts

Let's say we're about to write a cat clone. We want to accept a file to read into stdout. A simple example would be something like:

// my-app.ts

import { command, run, positional, string } from 'cmd-ts';

const app = command({
  /// name: ...,
  args: {
    file: positional({ type: string, displayName: 'file' }),
  },
  handler: ({ file }) => {
    // read the file to the screen
    fs.createReadStream(file).pipe(stdout);
  },
});

// parse arguments
run(app, process.argv.slice(2));

That works well! We already get autocomplete from TypeScript and we're making progress towards developer experience. Still, we can do better. In which ways, you might think?

  • Error handling is non existent, and if we'd implement it in our handler it'll be out of the command line argument parser context, making things less consistent and pretty.
  • It shows we lack composability and encapsulation — we miss a way to share and distribute "command line" behavior.

💡 What if we had a way to get a Stream out of the parser, instead of a plain string?

This is where cmd-ts gets its power from,

Custom Type Decoding

Exported from cmd-ts, the construct Type<A, B> is a way to declare a type that can be converted from A into B, in a safe manner. cmd-ts uses it to decode the arguments provided. You might've seen the string type, which is Type<string, string>, or, the identity: because every string is a string. Constructing our own types let us have all the implementation we need in an isolated and easily composable.

So in our app, we need to implement a Type<string, Stream>, or — a type that reads a string and outputs a Stream:

// ReadStream.ts

import { Type } from 'cmd-ts';
import fs from 'fs';

// Type<string, Stream> reads as "A type from `string` to `Stream`"
const ReadStream: Type<string, Stream> = {
  async from(str) {
    if (!fs.existsSync(str)) {
      // Here is our error handling!
      throw new Error('File not found');
    }

    return fs.createReadStream(str);
  },
};
  • from is the only required key in Type<A, B>. It's an async operation that gets A and returns a B, or throws an error with some message.
  • Other than from, we can provide more metadata about the type:
    • description to provide a default description for this type
    • displayName is a short way to describe the type in the help
    • defaultValue(): B to allow the type to be optional and have a default value

Using the type we've just created is no different that using string:

// my-app.ts

import { command, run, positional } from 'cmd-ts';

const app = command({
  // name: ...,
  args: {
    stream: positional({ type: ReadStream, displayName: 'file' }),
  },
  handler: ({ stream }) => stream.pipe(process.stdout),
});

// parse arguments
run(app, process.argv.slice(2));

Our handler function now takes a stream which has a type of Stream. This is amazing: we've pushed the logic of encoding a string into a Stream outside of our implementation, which free us from having lots of guards and checks inside our handler function, making it less readable and harder to test.

Now, we can add more features to our ReadStream type and stop touching our code which expects a Stream:

  • We can throw a detailed error when the file is not found
  • We can try to parse the string as a URI and check if the protocol is HTTP, if so - make an HTTP request and return the body stream
  • We can see if the string is -, and when it happens, return process.stdin like many Unix applications

And the best thing about it — everything is encapsulated to an easily tested type definition, which can be easily shared and reused. Take a look at io-ts-types, for instance, which has types like DateFromISOString, NumberFromString and more, which is something we can totally do.

Battery Packs

Batteries, from the term "batteries included", are optional imports you can use in your application but aren't needed in every application. They might have dependencies of their own peer dependencies or run only in a specific runtime (browser, Node.js).

Here are some battery packs:

File System Battery Pack

The file system battery pack contains the following types:

ExistingPath

import { ExistingPath } from 'cmd-ts/batteries/fs';

Resolves into a path that exists. Fails if the path does not exist. If a relative path is provided (../file), it will expand by using the current working directory.

Directory

import { Directory } from 'cmd-ts/batteries/fs';

Resolves into a path of an existing directory. If an existing file was given, it'll use its dirname.

File

import { File } from 'cmd-ts/batteries/fs';

Resolves into an existing file. Fails if the provided path is not a file.

URL Battery Pack

The URL battery pack contains the following types:

Url

import { Url } from 'cmd-ts/batteries/url';

Resolves into a URL class. Fails if there is no host or protocol.

HttpUrl

import { HttpUrl } from 'cmd-ts/batteries/url';

Resolves into a URL class. Fails if the protocol is not http or https

Parsers and Combinators

cmd-ts can help you build a full command line application, with nested commands, options, arguments, and whatever you want. One of the secret sauces baked into cmd-ts is the ability to compose parsers.

Argument Parser

An argument parser is a simple struct with a parse function and an optional register function.

cmd-ts is shipped with a couple of parsers and combinators to help you build your dream command-line app. subcommands are built using nested commands. Every command is built with flag, option and positional arguments. Here is a short parser description:

Positional Arguments

Read positional arguments. Positional arguments are all the arguments that are not an option or a flag. So in the following command line invocation for the my-app command:

my-app greet --greeting Hello Joe
       ^^^^^                  ^^^  - positional arguments

positional

Fetch a single positional argument

This parser will fail to parse if:

  • Decoding the user input fails

Config:

  • displayName (required): a display name for the named argument. This is required so it'll be understandable what the argument is for
  • type (required): a Type from string that will help decoding the value provided by the user
  • description: a short text describing what this argument is for

restPositionals

Fetch all the rest positionals

Note: this will swallaow all the other positionals, so you can't use positional to fetch a positional afterwards.

This parser will fail to parse if:

  • Decoding the user input fails

Config:

  • displayName: a display name for the named argument.
  • type (required): a Type from string that will help decoding the value provided by the user. Each argument will go through this.
  • description: a short text describing what these arguments are for

Options

A command line option is an argument or arguments in the following formats:

  • --long-key value
  • --long-key=value
  • -s value
  • -s=value

where long-key is "the long form key" and s is "a short form key".

There are two ways to parse options:

option

Parses one and only one option. Accepts a Type from string to any value to decode the users' intent.

In order to make this optional, either the type provided or a defaultValue function should be provided. In order to make a certain type optional, you can take a look at optional

This parser will fail to parse if:

  • There are zero options that match the long form key or the short form key
  • There are more than one option that match the long form key or the short form key
  • No value was provided (if it was treated like a flag)
  • Decoding the user input fails

Usage

import { command, number, option } from 'cmd-ts';

const myNumber = option({
  type: number,
  long: 'my-number',
  short: 'n',
});

const cmd = command({
  name: 'my number',
  args: { myNumber },
});

Config

  • type (required): A type from string to any value
  • long (required): The long form key
  • short: The short form key
  • description: A short description regarding the option
  • displayName: A short description regarding the option
  • defaultValue: A function that returns a default value for the option
  • defaultValueIsSerializable: Whether to print the defaultValue as a string in the help docs.

multioptions

Parses multiple or zero options. Accepts a Type from string[] to any value, letting you do the conversion yourself.

Note: using multioptions will drop all the contextual errors. Every error on the type conversion will show up as if all of the options were errored. This is a higher level with less granularity.

This parser will fail to parse if:

  • No value was provided (if it was treated like a flag)
  • Decoding the user input fails

Config

  • type (required): A type from string[] to any value
  • long (required): The long form key
  • short: The short form key
  • description: A short description regarding the option
  • displayName: A short description regarding the option

Flags

A command line flag is an argument or arguments in the following formats:

  • --long-key
  • --long-key=true or --long-key=false
  • -s
  • -s=true or --long-key=false

where long-key is "the long form key" and s is "a short form key".

Flags can also be stacked using their short form. Let's assume we have flags with the short form keys of a, b and c: -abc will be parsed the same as -a -b -c.

There are two ways to parse flags:

flag

Parses one and only one flag. Accepts a Type from boolean to any value to decode the users' intent.

In order to make this optional, either the type provided or a defaultValue function should be provided. In order to make a certain type optional, you can take a look at optional

This parser will fail to parse if:

  • There are zero flags that match the long form key or the short form key
  • There are more than one flag that match the long form key or the short form key
  • A value other than true or false was provided (if it was treated like an option)
  • Decoding the user input fails

Usage

import { command, boolean, flag } from 'cmd-ts';

const myFlag = option({
  type: boolean,
  long: 'my-flag',
  short: 'f',
});

const cmd = command({
  name: 'my flag',
  args: { myFlag },
});

Config

  • type (required): A type from boolean to any value
  • long (required): The long form key
  • short: The short form key
  • description: A short description regarding the option
  • displayName: A short description regarding the option
  • defaultValue: A function that returns a default value for the option
  • defaultValueIsSerializable: Whether to print the defaultValue as a string in the help docs.

multiflag

Parses multiple or zero flags. Accepts a Type from boolean[] to any value, letting you do the conversion yourself.

Note: using multiflag will drop all the contextual errors. Every error on the type conversion will show up as if all of the options were errored. This is a higher level with less granularity.

This parser will fail to parse if:

  • A value other than true or false was provided (if it was treated like an option)
  • Decoding the user input fails

Config

  • type (required): A type from boolean[] to any value
  • long (required): The long form key
  • short: The short form key
  • description: A short description regarding the flag
  • displayName: A short description regarding the flag

command

This is what we call "a combinator": command takes multiple parsers and combine them into one parser that can also take raw user input using its run function.

Config

  • name (required): A name for the command
  • version: A version for the command
  • handler (required): A function that takes all the arguments and do something with it
  • args (required): An object where the keys are the argument names (how they'll be treated in code) and the values are parsers
  • aliases: A list of other names this command can be called with. Useful with subcommands

Usage

#!/usr/bin/env YARN_SILENT=1 yarn ts-node

import {
  run,
  boolean,
  option,
  Type,
  flag,
  extendType,
  command,
  string,
} from '../src';

const PrNumber = extendType(string, {
  async from(branchName) {
    const prNumber = branchName === 'master' ? '10' : undefined;

    if (!prNumber) {
      throw new Error(`There is no PR associated with branch '${branchName}'`);
    }

    return prNumber;
  },
  defaultValue: () => 'Hello',
});

const Repo: Type<string, string> = {
  ...string,
  defaultValue: () => {
    throw new Error("Can't infer repo from git");
  },
  description: 'repository uri',
  displayName: 'uri',
};

const app = command({
  name: 'build',
  args: {
    user: option({
      type: string,
      env: 'APP_USER',
      long: 'user',
      short: 'u',
    }),
    password: option({
      type: string,
      env: 'APP_PASS',
      long: 'password',
      short: 'p',
    }),
    repo: option({
      type: Repo,
      long: 'repo',
      short: 'r',
    }),
    prNumber: option({
      type: PrNumber,
      short: 'b',
      long: 'pr-number',
      env: 'APP_BRANCH',
    }),
    dev: flag({
      type: boolean,
      long: 'dev',
      short: 'D',
    }),
  },
  handler: ({ repo, user, password, prNumber, dev }) => {
    console.log({ repo, user, password, prNumber, dev });
  },
});

run(app, process.argv.slice(2));

subcommands

This is yet another combinator, which takes a couple of commands and produce a new command that the first argument will choose between them.

Config

  • name (required): A name for the container
  • version: The container version
  • cmds: An object where the keys are the names of the subcommands to use, and the values are command instances. You can also provide subcommands instances to nest a nested subcommand!

Usage

import { command, subcommands, run } from 'cmd-ts';

const cmd1 = command({
  /* ... */
});
const cmd2 = command({
  /* ... */
});

const subcmd1 = subcommands({
  name: 'my subcmd1',
  cmds: { cmd1, cmd2 },
});

const nestingSubcommands = subcommands({
  name: 'nesting subcommands',
  cmds: { subcmd1 },
});

run(nestingSubcommands, process.argv.slice(2));

binary

A standard Node executable will receive two additional arguments that are often omitted:

  • the node executable path
  • the command path

cmd-ts provides a small helper that ignores the first two positional arguments that a command receives:

import { binary, command, run } from 'cmd-ts';

const myCommand = command({
  /* ... */
});
const binaryCommand = binary(myCommand);
run(binaryCommand, process.argv);

Building a Custom Parser

... wip ...