loading...

Creating a CLI for your Node.js app using Typescript

int0h profile image int0h ・8 min read

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:

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".

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

Using libraries

Using commander.js the example above could be rewritten like this:

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:

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

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:

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:

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

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?

node app.js -e dev -p abc

...it will output the following object:

{ _: [], 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:

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:

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

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:

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.

node app.js -e dev -p e

We-e-ell. Literally speaking it meets my expectations:

{ _: [], 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

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:

    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 :

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:

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:

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

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

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! :)

Posted on Sep 29 '19 by:

Discussion

markdown guide
 

First of all, thanks for your sharing.

I've tested check() and I'd suggest approach 2. Here's the detail. Cheers.

Approach 1 prints out the validation result like the following:

  .check(data => {
      return !isNaN(data.port);
  })

ts-node src/index.ts -e dev -p abc
Options:
  --help      Show help                                                [boolean]
  --version   Show version number                                      [boolean]
  --env, -e   app environment                [required] [choices: "dev", "prod"]
  --port, -p  port                                        [number] [default: 80]

Argument check failed: data => {
    return !isNaN(data.port);
}

Approach 2 prints out only what I coded

...
.check(data => {
  if (isNaN(data.port)) {
    throw new Error('port is not a number');
  } 
  else {
    return data.port;
  }
})

ts-node src/index.ts -e dev -p abc
Options:
  --help      Show help                                                [boolean]
  --version   Show version number                                      [boolean]
  --env, -e   app environment                [required] [choices: "dev", "prod"]
  --port, -p  port                                        [number] [default: 80]

port is not a number

Yargs API says If fn throws or returns a non-truthy value, show the thrown error, usage information, and exit., so I modified check() so that it threw an error if data.port is NaN and worked like above.

 

Yargs sadly fails to support the super-common idiom of "subset of allowed values". Setting the "choices" array for an option breaks the "array" functionality: the value is still returned as array, but only contains the first element specified on the command line.

 

I tried to reproduce it. But yargs worked for me. Both app -o a -o b and app -o a b printed o: ['a', 'b']. The first approach also worked with typed-cli. Can you provide with a code sample of what you mean exactly?

 

Turns out it was not choices that was the culprit but requiresArg. It's an embarrasing, long-standing bug in yargs. I made a PR to revert the commit that introduced it.