Creating a CLI for your Node.js app using Typescript
Tags: cli, typescript, node, terminal
Published: 2019-09-29 19:30:15

---
title: Creating a CLI for your Node.js app using Typescript
published: true
description: This article is basically an overview of existing CLI helper libraries and their usage alongside Typescript. It is also a humble presentation of my own solution.
tags: CLI, Typescript, Node.js, terminal
---

# What's that?

This article is basically an overview of existing CLI helper libraries
and their usage alongside Typescript.
It is also a humble presentation of my own solution.

# Story

One day I wanted to create a CLI tool in Node.js.
It was supposed to be launched from terminal, and it was supposed to accept some CLI arguments and options.

So I could have written something like this:
```js
const [env] = process.argv.slice(2);

function main({ env }) {
    // ...
}

main({ env });
```

It would work perfectly fine and I believe such approach is the most appropriate in some cases.
But predictably at some point I needed to support something else except the "env".

```js
const [env, _dryRunFlag] = process.argv.slice(2);

const isDryRun = Boolean(_dryRunFlag);

function main({ env, isDryRun }) {
    // ...
}

main({ env, isDryRun });
```

It's not hard to tell how problematic this code is. But there it is not a problem! All I needed is **argument parser**.

# Options

- https://github.com/substack/node-optimist - Deprecated by maintainers
- https://github.com/substack/minimist - great low-level library, but not activelly supported (last update: 4 years ago)
- https://github.com/trentm/node-dashdash - (last update: 3 years ago)
- https://github.com/harthur/nomnom - (last update: 5 years ago)

- https://github.com/tj/commander.js - ✅
- https://github.com/yargs/yargs - ✅
- https://github.com/trentm/node-dashdash - ✅

# Using libraries

Using **commander.js** the example above could be rewritten like this:
```js
const program = require('commander');

program
  .option('-e, --env', 'app environment')
  .option('-n, --dry-run', 'pretend to do things')

program.parse(process.argv);

console.log(program);
```

It will work fine. Let's see how **yargs** configuration will look like:

```js
const yargs = require('yargs');

const argv = yargs.options({
    env: {
        alias: 'e',
        choices: ['dev', 'prod'],
        demandOption: true,
        description: 'app environment'
    },
    port: {
        alias: 'p',
        default: 80,
        description: 'port'
    }
  })
    .argv;

console.log(argv);
```

Also fine!

But since we are using a third party library, we probably want to check out some features shipped with them.

# Features

- typescript/flow support
- data validation
- `--help` generation
- <tab> completions
etc.

For me the cornerstone was the first. I love TypeScript.

Let me show you how it works.

# Types

If you want to use Typescript in your project you probably would like to have the data typed. So instead of working with `unknown` or `any` you will be able to operate with numbers or booleans etc.

Unfortunately **Commander**'s typings help you to write CLI configuration code but it won't help you to get type of the data a user can pass to the app. So if you are going to use yargs you might want to stick to the **yargs**.

Using **yargs** and with a few tweaks in the code you can end up with this code:
```ts
import * as yargs from 'yargs';

const argv = yargs.options({
    env: {
        alias: 'e',
        choices: ['dev', 'prod'] as const,
        demandOption: true,
        description: 'app environment'
    },
    port: {
        alias: 'p',
        default: 80,
        description: 'port'
    }
  })
    .argv;

console.log(argv);
```

*Disclaimer: I'm using **yargs** version `14.0.0` and **@types/yargs** version `^13.0.3`*

In this example the type of `argv` will be resolved to:
```ts
const argv: {
    [x: string]: unknown;
    env: "dev" | "prod";
    port: number;
    _: string[];
    $0: string;
}
```

Which is quite impressive.
So now you can go on and work with your data accordingly to types... right?
Let's see.

If you call this app with no arguments:
```sh
node app.js
```

It will output the help text and will complain that you did not provide `env` option:
```
Options:
  --help      Show help                                                [boolean]
  --version   Show version number                                      [boolean]
  --env, -e   app environment                [required] [choices: "dev", "prod"]
  --port, -p  port                                                 [default: 80]

Missing required argument: env
```

That's nice! So **yargs** will throw an error when you pass invalid data... kind of...

This command
```sh
node app.js --env abc
```
will produce the help text and an error message:
```
Invalid values:
  Argument: env, Given: "abc", Choices: "dev", "prod"
```
Also great!

What if I pass some rubbish as port, though?
```sh
node app.js -e dev -p abc
```
...it will output the following object:
```js
{ _: [], e: 'dev', env: 'dev', p: 'abc', port: 'abc', '$0': 'foo' }
```
Whoa! It is not what I expected! The obvious problem here is that I can write something like this:
```ts
console.log(argv.port.toFixed(0))
```
and it will fail with

```
TypeError: argv.port.toFixed is not a function
```
But the biggest problem is that **`argv` has a wrong type**! I'm not only to make that mistake, but
my Typescript compiler will eat it also. But the worst part is that my IDE will show me the type of
`args.port` as `number`. As for me, *having a wrong type is much worse than having no type at all*.

So what exactly went wrong here? Actually I just missed the type of the option:
```ts
const argv = yargs.options({
    env: {
        alias: 'e',
        choices: ['dev', 'prod'] as const,
        demandOption: true,
        description: 'app environment'
    },
    port: {
        alias: 'p',
        type: 'number',
        default: 80,
        description: 'port'
    }
  })
    .argv;
```
I guess, without explicit type **yargs** treats the type automatically regardless the default value. While
**@types/yargs** infers the type from `default` property:
https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/yargs/index.d.ts#L711
```ts
type InferredOptionType<O extends Options | PositionalOptions> =
    O extends { default: infer D } ? D :
    O extends { type: "count" } ? number :
    O extends { count: true } ? number :
    O extends { required: string | true } ? RequiredOptionType<O> :
    O extends { require: string | true } ? RequiredOptionType<O> :
    O extends { demand: string | true } ? RequiredOptionType<O> :
    O extends { demandOption: string | true } ? RequiredOptionType<O> :
    RequiredOptionType<O> | undefined;
```

Okay, so I will fix that:

```ts
import * as yargs from 'yargs';

const argv = yargs.options({
    env: {
        alias: 'e',
        choices: ['dev', 'prod'] as const,
        demandOption: true,
        description: 'app environment'
    },
    port: {
        alias: 'p',
        type: 'number', // added the type
        default: 80,
        description: 'port'
    }
  })
    .argv;

console.log(argv);

console.log(argv.port.toFixed(0));
```

Now I expect to receive either `number` or to see help text once again and the error message.
```sh
node app.js -e dev -p e
```
We-e-ell. Literally speaking it meets my expectations:
```js
{ _: [], e: 'dev', env: 'dev', p: NaN, port: NaN, '$0': 'foo' }
NaN
```
I did not get the error message because I got the `number`, as long as you define a number
as
```js
const isNumber = value => typeof value === 'number';
```

But nevertheless I expected an error here. Can we fix that? Yes, we can!
Yargs supports data validation: http://yargs.js.org/docs/#api-checkfn-globaltrue

So I will fix the code example:
```ts
    env: {
        alias: 'e',
        choices: ['dev', 'prod'] as const,
        demandOption: true,
        description: 'app environment'
    },
    port: {
        alias: 'p',
        type: 'number',
        default: 80,
        description: 'port'
    }
  })
  .check(data => { // data is actually typed here, which is also nice
      // at this point data.port is already NaN so you can not use typeof
      return !isNaN(data.port);
  })
    .argv;
```
Now if I pass any inappropriate value I will get an error:
```
Argument check failed: ...
```
Which is nice! You have to operate with whole data, though.
So if you have 10 options needing validation you will have to
(unless I miss something of course) declare these 10 options in one place
and validate in one `.check(...)` call containing 10 checks.

Also you can use `.coerce(...)` http://yargs.js.org/docs/#api-coercekey-fn :
```ts
const argv = yargs.options({
    env: {
        alias: 'e',
        choices: ['dev', 'prod'] as const,
        demandOption: true,
        description: 'app environment'
    },
    port: {
        alias: 'p',
        type: 'number',
        default: 80,
        description: 'port'
    }
  })
    .coerce('port', port => { // port is not typed, but it is fine
        // at this point port is actual string you passed to the app
        // or the default value so it should be `string | number`
        // in this case
        const result = Number(port);
        if (isNaN(result)) {
            throw new Error('port is not a number');
        }
        return result;
    })
    .argv;

console.log(argv);
```

`.coerce(...)` is used to *transform* provided options, but also it allows to throw errors,
so you can validate data using it. I'm not sure whether you supposed to though.

### Final version

The final version of the app looks like this:
```ts
import * as yargs from 'yargs';

const argv = yargs.options({
    env: {
        alias: 'e',
        choices: ['dev', 'prod'] as const,
        demandOption: true,
        description: 'app environment'
    },
    port: {
        alias: 'p',
        type: 'number',
        default: 80,
        description: 'port'
    }
  })
  .check(data => {
      return !isNaN(data.port);
  })
    .argv;

console.log(argv);
```

Features:
- safely typed
- validate user input and provide error messages
- generate help text with `--help` flag

### Nullability

I should say that **yargs** (and **@types/yargs**)
handles typing optional/required options quite good out of the box.
So if you neither provide the default value nor mark
the option as required the option value will be
nullable:
```ts
const argv = yargs.options({
    optional: {
        type: 'string'
    }
  })
    .argv;

args.optional // is `string | undefined`

```
So:
  - optional => `T | undefined` in result types
  - required => either it is provided or an error will be thrown
  - has default value => if option is not provided - the default value will be used

### Disclaimer

Actually I'm impressed by both **yargs** it-self and **@types/yargs**.

- yargs supports huge amount of features, including
  - input validation
  - help generation
  - `tab` completions
  - data transformations
  - commands
  etc.

More than that **yargs** has one of the best external
typing I ever seen. 👏👏👏 Applause to the creators.

The types covers not only the library interface but also
the result data.

### Conclusion

If you are creating a Typescript application that should support
CLI, **yargs** is one of the best tools you can use.

But I suggest you to try one more thing before you go...

# Typed-cli

At some point I realized that I created a similar
project. It's called **typed-cli** and it's also a library to
help you create CLI's.

It supports some of the features of **yargs**, such as:
  - input validation
  - help generation
  - `tab` completions
  - data transformations
  - commands

It does **not** support some features of **yargs** such as:
  - `.implies()`
  - `.conflicts()`
  - positional arguments
  - counter options (`-vvv`)
and some others

Some of them probably will be supported in future, some of them not.

Also it also has some features that **yargs** does not (as far as I know at least):
  - treats aliases conflicts
  - automatically creates *kebab* aliases `cfgName` -> `cfg-name`
  - probably treats completions differently (I'm not sure what exactly **yargs** provides)
  - it produces output in the different format, utilizes colors in terminal (configurable)

And the most important: it is **type-first**. So
every its feature was developed with types in mind.
- it is designed in a
way that when you declare an option of `number` type you will get a number
or an error will be thrown.
- it does not populate result object with
aliases. So the result data will be exactly the same as it's typed.
- it is a typescript project made for typescript projects (with backward compatibility with JS of course).
So it guarantees (to some extent) that the typing and library itself won't diverge
now or in the future.

### Relation with yargs

**typed-cli** uses **yargs-parser** under the hood. So it could
be considered as something like alternative frontend for it.

But the dependency is lose so there is an opportunity to change the
parser in the future.

### State of the project

Right now it is rather an alpha version that can contain some bugs or
lack some features. But it can do a lot already.

One of the reasons I'm writing this article is to present my
work and to see whether it is interesting for anyone. Depending
on that the project can get either more attention and development or
be forgotten.

### Why it created

I did not try to compete to **yargs** while I was working on that.
It was created almost accidentally from my other project. At some stage I realized
that my work might be useful for the community.

### Example

```ts
import {cli, option} from 'typed-cli';

const argv = cli({
    options: {
        env: option.oneOf(['dev', 'prod'] as const)
            .alias('e')
            .required()
            .description('app environment'),
        port: option.int
            .alias('p')
            .default(80)
            .description('port'),
    }
});

console.log(argv);
```

This code includes:
- `argv` typing
- input validation
- help generation
- `tab` completion

That's how it look like
![demo-gif](https://github.com/int0h/typed-cli/raw/master/demo.gif)

You can see more on the project GitHub: https://github.com/int0h/typed-cli

Also I have created quite functional demo page, that you can use
online and test most of the features **without installing anything on your machine**.

You can find it here: https://int0h.github.io/typed-cli-pg/
*(in the case something does not work - try reload the page)*

# Epilogue

I hope the article was useful for you and you enjoyed reading it.

Please let me know if you have any feedback to the article or **typed-cli**.

And thank you for your time! :)