args-minus
v0.2.1
Published
A less-is-more `parseArgs`: runtime-neutral command-line tokenizer and parser
Maintainers
Readme
args-minus
A less-is-more way to parse command line input.
Why?
There are many command line parsers out there, why args-minus?
In short, they are doing too much. Too much in a sense that they impose certain assumptions to the input.
For example, some parsers assume git-style sub commands, and others has various options to parse the whole input one way or the other.
To put in another way, they are opinionated in some shape or form.
It's not like opinionated tools are bad. In fact, they are quite popular. By taking away certain flexibility, it simplifies the consumption (in code) and normalizes usage (for user).
The problem is where to define that opinion. There are 3 obvious candidates: library, framework, or application.
Application has all the rights to be opinionated. It is an application providing certain specific values to the end user after all.
Framework can be opinionated. An opinionated framework is good at building certain application, but bad at others. But that's ok. That's the price to pay to get the benefits of using a certain framework.
Library should not be opinionated. Library should do one thing and do it well. It should support as many scenarios as possible, and should defer decisions to frameworks or applications.
Some command line parsing packages are frameworks, which are fine, but other packages are designed as a library, but act more like a framework.
args-minus is designed to be a library.
Its sole purpose is to provide a convenient and consistent way to parse command line input,
in the way you want to parse it.
Installation
npm install args-minus
pnpm add args-minus
yarn add args-minus
deno add args-minus
bun add args-minusSupported runtimes
| Runtime | Supported | | --- | --- | | Node.js | 20 and later | | Deno | 2 and later | | Bun | 1 and later | | Browsers and workers | current evergreen versions |
The package is ESM only, has no dependencies, and uses no Node API, so any
runtime that loads ES modules works. The exports map has one entry, ., with
types, import and default conditions pointing at the same files in esm/.
CI builds the package and imports it by name in Node, Deno and Bun on every
pull request. There is no CommonJS build; on Node, require() of an ES module
works from Node 22.12.
Usage
import { parseArgs, parseArgsAsync, t, tokenizeArgs } from 'args-minus'The package has three functions, tokenizeArgs(), parseArgs() and
parseArgsAsync(), the option type builders t, and the ParseArgsError
class. It depends on no runtime API, only on the JavaScript language, so it
runs wherever ES modules do.
Both take the arguments already split into an array: pass process.argv.slice(2)
(Node), Deno.args, or Bun.argv.slice(2).
Examples in this document
Every example in this section is executed by src/readme.spec.ts. A line
starting with // => is the expected value of the expression above it, and
// throws is a substring of the expected error message.
tokenizeArgs(args, grammar?)
Splits arguments into tokens in source order. It makes no decisions about values: it does not know which options exist, and it never consumes the next argument.
| Token | Fields |
| --- | --- |
| { kind: 'option' } | index, name (without modifiers), dashes (number of leading modifier characters), raw (the argument as given), value (text after the first =, if any) |
| { kind: 'positional' } | index, value |
| { kind: 'option-terminator' } | index |
index is the position in the input array and is never reordered.
tokenizeArgs(['--name=Ada', '-v', 'file', '--', '-x'])
// => [{ kind: 'option', index: 0, name: 'name', dashes: 2, raw: '--name=Ada', value: 'Ada' }, { kind: 'option', index: 1, name: 'v', dashes: 1, raw: '-v', value: undefined }, { kind: 'positional', index: 2, value: 'file' }, { kind: 'option-terminator', index: 3 }, { kind: 'positional', index: 4, value: '-x' }]Rules:
- An argument starting with one or more modifier characters (
-by default) followed by a name is an option.-alone, and an argument made only of modifiers (other than the terminator), is a positional. =splits the name from an inline value at its first occurrence. An empty value (--a=) is the empty string.- The terminator (
--by default) is matched exactly. Every later argument is a positional, even if it looks like an option.
tokenizeArgs(['-', '--a='])
// => [{ kind: 'positional', index: 0, value: '-' }, { kind: 'option', index: 1, name: 'a', dashes: 2, raw: '--a=', value: '' }]Grammar
The second argument changes the syntax. Defaults: { modifiers: '-', terminator: '--' }.
modifiers: a string or array of single characters (not=) that start an option. Their count is reported indashes.terminator: the exact argument that ends option parsing.
An invalid grammar throws an Error. The token order, index and raw never
depend on the grammar.
tokenizeArgs(['/v', '::', '/x'], { modifiers: '/', terminator: '::' })
// => [{ kind: 'option', index: 0, name: 'v', dashes: 1, raw: '/v', value: undefined }, { kind: 'option-terminator', index: 1 }, { kind: 'positional', index: 2, value: '/x' }]parseArgs(config)
Applies option declarations to the tokens.
parseArgs({ args: ['--name', 'Ada', 'file'], options: { name: { type: 'string' } } })
// => { _: ['file'], __: [], options: { name: 'Ada' } }Config:
| Field | Meaning | Default |
| --- | --- | --- |
| args | The arguments | required |
| options | Declarations keyed by canonical option name | none |
| strict | Throw on undeclared options | false |
| allowPositionals | Accept positional arguments | true |
| grammar | Same as for tokenizeArgs | - and -- |
| tokens | Also return the raw tokens | false |
Result:
_: positional arguments before the terminator, in order.__: arguments after the terminator, in order.options: parsed values by canonical name.tokens: thetokenizeArgsoutput, only whentokens: true.
Declarations
{ type: 'boolean' | 'number' | 'string', alias?, multiple?, default? }
{ type: t.int({ min: 1 }), alias?, default? }type is a type name or a descriptor from t (next section).
parseArgs is generic over the declarations, so options is typed from them:
number, boolean, string, an array when multiple, and non-optional when
there is a default.
Option types: t
A descriptor from t converts the raw argument to a typed value and carries
its constraints as data.
| Builder | Converts the argument to | Data |
| --- | --- | --- |
| t.string(opts?) | the text, matching pattern if given | pattern? |
| t.string<T>({ pattern }) | the text, typed as T | pattern |
| t.number(opts?) | a finite number | min?, max? |
| t.int(opts?) | an integer | min?, max? |
| t.boolean() | true when present; takes no value | |
| t.literal(value) | exactly value (string, number or boolean) | value |
| t.enum([...]) | one of a non-empty tuple of strings | values |
| t.array(element) | every occurrence, each converted by element | element |
| t.custom(raw => value) | what the function returns or resolves to | convert |
| t.json(schema?) | the value of the JSON text | schema? |
parseArgs({ args: ['--port', '8080', '--tag=a', '--tag=b', '--level=warn'], options: { port: { type: t.int({ min: 1, max: 65535 }) }, tag: { type: t.array(t.string()) }, level: { type: t.enum(['info', 'warn']) }, dry: { type: t.boolean().optional() } } }).options
// => { port: 8080, tag: ['a', 'b'], level: 'warn' }
parseArgs({ args: ['--port=0'], options: { port: { type: t.int({ min: 1, max: 65535 }) } } })
// throws Invalid value for option 'port': expected an integer from 1 to 65535, got '0' (from '--port=0')Rules:
- A descriptor option is required unless it is
.optional()or has adefault. A required option that is absent throws. t.number()andt.int()are strict: an empty value,NaN,Infinityor trailing text is an invalid value. Both take a negative number as the next argument (--n -5).t.boolean()and a booleant.literal()are flags: they never take the next argument, and accept only=trueor=falseinline.t.string<T>({ pattern })is an assertion: TypeScript cannot checkTagainst the pattern.t.literal()andt.enum()infer the exact values:t.literal(true)istrue, andt.enum(['a', 'b'])is'a' | 'b'.t.array(element)collects every occurrence in source order. The element is any descriptor that is neither optional nor an array. It replacesmultiple, which a descriptor declaration does not take.t.json()isunknown, andt.json<T>()assertsTafter checking only the JSON syntax.t.json(schema)validates with a Standard Schema and uses its output. The JSON comes from the argument text only; reading a file or stdin is left to the application.t.custom(convert)throwing a plainError(whose constructor isError) is an invalid value with that message. Any other throw propagates.- A
defaultis not converted. Its checks run on the first parse that uses its declaration; a failure there throws a plainError, not aParseArgsError. - Definitions are checked when built:
min <= max, integer bounds ont.int(), a non-emptyt.enum(), and a scalart.literal(). A bad one throws.
.optional() and .check() are the only methods, and each returns a new
descriptor. The descriptor's own fields are its data, a read-only union keyed
on kind (Data), so { ...t.int({ min: 1 }) } is
{ kind: 'int', min: 1, isOptional: false, checks: [] }.
isDescriptor(value) tells a descriptor apart from other values. It accepts a
descriptor from any copy of args-minus loaded in the same process, such as a
copy bundled into a CLI and another installed for its plugins, as long as both
use the same descriptor format. A copy of the data ({ ...descriptor }) is not
a descriptor.
Checks
.check() validates the converted value. It never changes the value or its
type. It takes:
- a function that returns
trueor nothing to pass,falseto fail, or a string to fail with that message, or a promise of one of these; - any Standard Schema (zod, valibot, arktype, …). Its output is ignored, and an
async
validateis awaited.
Checks run in declaration order and the first failure wins. A check that throws
propagates. On t.array(), an element's checks run on each element and the
array's checks on the whole array.
parseArgs({ args: ['--n=3'], options: { n: { type: t.int().check((n) => n % 2 === 0 || 'must be even') } } })
// throws Invalid value for option 'n': must be even (from '--n=3')
parseArgs({ args: ['--tag=a', '--tag=B'], options: { tag: { type: t.array(t.string({ pattern: /^[a-z]+$/ })) } } })
// throws Invalid value for option 'tag[1]'Asynchronous options: parseArgsAsync(config)
A t.custom() converter, a check, or a schema may be asynchronous.
parseArgsAsync() takes the same config as parseArgs(), awaits them one at a
time in input order, and resolves to the same result. parseArgs() stays
synchronous: when one of them returns a promise, it throws an Error that
names the option and asks for parseArgsAsync().
Options, dashes, and inline values
- One modifier is a short group: every character is an option.
-abcisa,bandc. - Two or more modifiers are one long option:
--abcisabc. - A value is the inline
=value, or, for a declaredstringornumberoption, the next argument. For a short group the value belongs to the last character. - An option with no value is
true.
parseArgs({ args: ['-abc=1', '--long'] }).options
// => { a: true, b: true, c: '1', long: true }Values that follow an option
A declared string or number option takes the next argument. boolean and
undeclared options never do, so the next argument stays positional.
- The next argument is not taken if it looks like an option (
-x,--x) or is the terminator. The option then fails as missing a value. Use the inline form for such a value:--name=--x. - A
numberoption does take a negative number:--n -5. - Consumed arguments are not positionals. The rest keep their order.
parseArgs({ args: ['a', '--n', '-5', 'b'], options: { n: { type: 'number' } } })
// => { _: ['a', 'b'], __: [], options: { n: -5 } }
parseArgs({ args: ['--name', '--x'], options: { name: { type: 'string' } } })
// throws option 'name': a value is requiredConversion
| Type | Accepts | Result |
| --- | --- | --- |
| string | any text | the text |
| number | a finite number (1, -2.5, 0x10) | number |
| boolean | no value, true/t, false/f, or a number (> 0 is true) | boolean |
Undeclared options are true or the raw string. Conversion also applies to
aliases and the last option of a short group.
parseArgs({ args: ['--n=1.5', '--b=0'], options: { n: { type: 'number' }, b: { type: 'boolean' } } }).options
// => { n: 1.5, b: false }
parseArgs({ args: ['--n=abc'], options: { n: { type: 'number' } } })
// throws Invalid value for option 'n': abc (from '--n=abc')Aliases and short groups
alias is a name or array of names without modifiers. The result always uses
the declared name. Aliases are resolved in groups too; a value-taking option
may only be last in a group, or parseArgs throws.
parseArgs({ args: ['-vn', 'Ada'], options: { verbose: { type: 'boolean', alias: 'v' }, name: { type: 'string', alias: 'n' } } }).options
// => { verbose: true, name: 'Ada' }
parseArgs({ args: ['-nv'], options: { verbose: { type: 'boolean', alias: 'v' }, name: { type: 'string', alias: 'n' } } })
// throws Invalid option group '-nv'Two options may not share a name or alias; that throws an Error.
Repeated options and defaults
- Default: last occurrence wins.
multiple: falsestates it explicitly. multiple: truecollects every occurrence, in source order, into an array, converting each.defaultis used only when the option is absent (also through its aliases) and must match the declared type (an array whenmultiple).
parseArgs({ args: ['-i', 'a', '--inc=b'], options: { inc: { type: 'string', multiple: true, alias: 'i' }, n: { type: 'number', default: 3 } } }).options
// => { inc: ['a', 'b'], n: 3 }Unknown options and positionals
By default undeclared options are accepted and kept. With strict: true each
undeclared name (including inside a short group) throws; aliases count as
declared. Input after the terminator is never checked. allowPositionals: false
throws on any positional, including those after the terminator, and is
independent of strict.
parseArgs({ args: ['--nope'], options: { a: { type: 'boolean' } }, strict: true })
// throws Unknown option 'nope' (from '--nope')
parseArgs({ args: ['x'], allowPositionals: false })
// throws Unexpected positional argument 'x'Errors
Invalid input throws ParseArgsError (an Error) with:
kind:unknown-option,unexpected-positional,invalid-group,missing-value,invalid-valueormissing-option,option: the option name (undefinedfor an unexpected positional),input: the argument as written, e.g.--n=abc(empty for a missing option),index: for an array option, the index of the element that failed,issues: the issues from a Standard Schema that failed. The message never repeats a schema's text,value: the raw value that failed, as typed (undefinedwhen none was given),detail: what was wrong without the option and input framing ofmessage: what the descriptor expected (expected an integer from 1 to 65535), a check's own message, or a converter's error message. A framework that reports errors in its own words reads this instead of parsingmessage.
A mistake in the declarations or grammar (duplicate alias, bad modifier) throws
a plain Error. parseArgs never returns NaN or undefined for a declared
option.
Compatibility
args-minus follows semantic versioning. These are stable and change only in a
major version: the exports above, the token shapes and field names, the
descriptor data, the parsing rules in this document, and the error class and
its fields. Error message text is informative; match on ParseArgsError and
its fields instead.
