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 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.
typescript/flow support - data validation - help generation - completions etc.
For me the cornerstone was the first. Let me show you how it works.
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.
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
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
Actually I’m impressed by both yargs it-self and @types/yargs.
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.
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