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
- 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)
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
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! :)
Top comments (5)
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
andapp -o a b
printedo: ['a', 'b']
. The first approach also worked withtyped-cli
. Can you provide with a code sample of what you mean exactly?Turns out it was not
choices
that was the culprit butrequiresArg
. It's an embarrasing, long-standing bug in yargs. I made a PR to revert the commit that introduced it.I got TypeError using
choices
witharray
in latest version 17.6.2First 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:
Approach 2 prints out only what I coded
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.