How to Build a CLI for Node.js application using Typescript

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.

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. 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:
here

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

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(...)

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

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

This article was originally published on dev.to

#typescript #node-js #javascript #web-development

How to Build a CLI for Node.js application using Typescript
5 Likes54.50 GEEK