This document describes the design philosophy, core concepts, and internal architecture of bargs.
bargs is a thin, type-safe wrapper around Node.js's built-in util.parseArgs(). Rather than reinventing argument parsing, bargs delegates the low-level tokenization to Node's battle-tested implementation and focuses on what it does best: type inference and composable schema definitions.
| Approach | Example | Trade-off |
|---|---|---|
| Builder chains | yargs | Powerful but complex; types often require manual annotation |
| Decorator-based | oclif | Requires classes and experimental decorators |
| Schema DSLs | commander | Simple but limited type inference |
| Parser combinators | bargs | Composable with full inference; requires understanding the mental model |
bargs chooses parser combinators because they naturally express the "build up a schema, then parse" pattern while preserving types at each step.
| Term | Description |
|---|---|
| Option | A named flag like --verbose or --output file.txt |
| Positional | An unnamed argument like file.txt in cat file.txt |
| Parser | A data structure holding option and positional schemas with their inferred types |
| Command | A Parser with an attached handler function |
| Handler | A function that receives parsed values and positionals |
| Transform | A function that modifies parsed results before they reach handlers |
The Parser<TValues, TPositionals> is the central data structure. It represents accumulated parse state and carries two kinds of information:
interface Parser<TValues, TPositionals extends readonly unknown[]> {
// Runtime schemas (used for actual parsing)
readonly __optionsSchema: OptionsSchema;
readonly __positionalsSchema: PositionalsSchema;
// Compile-time phantom types (used for type inference)
readonly __values: TValues;
readonly __positionals: TPositionals;
// Brand for runtime type discrimination
readonly __brand: 'Parser';
}
Why branded types instead of classes?
bargs uses a __brand field for runtime type discrimination rather than instanceof checks. This choice enables better TypeScript structural typing—the compiler can infer and merge types without fighting class hierarchies or prototype chains. In other words: have you ever tried to use mixins in TypeScript?
A Command<TValues, TPositionals> is a Parser that has been paired with a handler function. It's the "terminal" in a pipeline—once you attach a handler, the parser is ready to execute.
interface Command<TValues, TPositionals extends readonly unknown[]> {
readonly __brand: 'Command';
readonly __optionsSchema: OptionsSchema;
readonly __positionalsSchema: PositionalsSchema;
readonly handler: HandlerFn<TValues, TPositionals>;
}
The CliBuilder provides a fluent API for registering commands and configuring a CLI:
bargs('my-cli', { version: '1.0.0' })
.globals(globalParser) // Set global options
.command('add', parser, handler) // Register a command
.defaultCommand('list') // Set default command
.parseAsync(); // Execute
The builder maintains internal state including registered commands, aliases, global parser, and theme configuration.
Types flow from individual option definitions through parsers to command handlers:
flowchart TD
subgraph defs [Schema Definitions]
A["opt.boolean({ aliases: ['v'] })"]
B["opt.string({ default: 'out.txt' })"]
C["opt.options({ verbose: ..., output: ... })"]
end
subgraph parsers [Parser Layer]
D["Parser#lt;V, P#gt;"]
E["map(parser, transformFn)"]
F["merge(parser1, parser2)"]
end
subgraph commands [Command Layer]
G["CliBuilder.command()"]
H["Handler receives merged types"]
end
A --> C
B --> C
C --> D
D --> E
E --> D
D --> F
F --> D
D --> G
G --> HOption builders (opt.boolean(), opt.string(), etc.) return typed definitions that preserve all properties:
const verbose = opt.boolean({ aliases: ['v'] });
// Type: BooleanOption & { aliases: ['v'] }
opt.options() transforms a schema object into a Parser with inferred values:
const parser = opt.options({
verbose: opt.boolean(),
count: opt.number({ default: 0 }),
});
// Type: Parser<{ verbose: boolean | undefined, count: number }, []>
Transforms via map() can change the shape of parsed results:
const transformed = map(parser, ({ values, positionals }) => ({
values: { ...values, timestamp: Date.now() },
positionals,
}));
// Type: Parser<{ verbose: boolean | undefined, count: number, timestamp: number }, []>
Handlers receive the fully-merged type (globals + command-local):
.command('build', buildParser, ({ values }) => {
// values: GlobalOptions & BuildCommandOptions
})
Both opt.options() and pos.positionals() return callable parsers—objects that are simultaneously:
Parser (with all schema properties)This enables pipe-style composition:
// Left-to-right: positionals first, then merge options into them
const parser = pos.positionals(pos.string({ name: 'input', required: true }))(
opt.options({ verbose: opt.boolean() }),
);
// Type: Parser<{ verbose: boolean | undefined }, [string]>
The call syntax positionals(options) reads as "take positionals, merge in options." This is the primary composition style in bargs.
For those who prefer explicit function calls:
const parser = merge(
pos.positionals(pos.string({ name: 'input', required: true })),
opt.options({ verbose: opt.boolean() }),
);
Both approaches produce identical results. Use whichever reads more clearly for your use case.
flowchart LR
subgraph creation [Create Parsers]
O["opt.options({ ... })"]
P["pos.positionals(...)"]
end
subgraph compose [Compose]
M1["positionals(options)"]
M2["merge(p1, p2, ...)"]
end
subgraph transform [Transform]
T["map(parser, fn)"]
end
subgraph use [Use]
C["CliBuilder.command()"]
G["CliBuilder.globals()"]
end
O --> M1
P --> M1
O --> M2
P --> M2
M1 --> T
M2 --> T
T --> C
T --> G
M1 --> C
M1 --> G
M2 --> C
M2 --> GNode's util.parseArgs() is responsible for:
--verbose, -v)-v from short: 'v')bargs wraps parseArgs() and adds:
| Feature | Description |
|---|---|
| Type coercion | Converts strings to numbers, validates enum choices |
| Default values | Applies defaults when options are omitted |
| Boolean negation | --no-verbose sets verbose to false |
| Multi-char aliases | --verb as alias for --verbose (parseArgs only supports single-char) |
| Positional parsing | Typed positionals with required/optional, variadic support |
| Validation | Required options, enum choices, alias conflicts |
| Help generation | Themed, formatted help text with grouping |
| Commands | Subcommand dispatch with merged globals |
flowchart TD
subgraph input [Input]
A["process.argv.slice(2)"]
end
subgraph node [Node.js parseArgs]
B["util.parseArgs()"]
C["{ values, positionals }"]
end
subgraph bargs_processing [bargs Processing]
D["processNegatedBooleans()"]
E["collapseAliases()"]
F["coerceValues()"]
G["coercePositionals()"]
end
subgraph transforms [Transforms]
H["Global transforms"]
I["Command transforms"]
end
subgraph output [Output]
J["ParseResult#lt;V, P#gt;"]
K["Handler execution"]
end
A --> B
B --> C
C --> D
D --> E
E --> F
F --> G
G --> H
H --> I
I --> J
J --> K| Module | Responsibility |
|---|---|
types.ts |
All type definitions: option/positional interfaces, Parser, Command, CliBuilder, and inference utilities (InferOptions, InferPositionals) |
opt.ts |
Builder functions for options (opt.string(), opt.boolean(), etc.) and positionals (pos.string(), pos.variadic(), etc.), plus the opt.options() and pos.positionals() callable parser factories |
parser.ts |
Low-level wrapper around util.parseArgs(). Handles config building, type coercion, boolean negation, and alias collapsing |
bargs.ts |
The bargs() entry point, CliBuilder implementation, and combinator functions (map, merge, handle, camelCaseValues) |
help.ts |
Help text generation with theming, grouping, and formatting |
theme.ts |
ANSI styling, built-in themes, and custom theme support |
osc.ts |
Terminal hyperlink support (OSC 8 sequences) |
errors.ts |
Error classes: BargsError, HelpError, ValidationError |
validate.ts |
Schema validation utilities |
bargs supports arbitrarily nested command hierarchies (e.g., git remote add). The factory pattern provides full type inference for parent globals in nested handlers.
bargs('git')
.globals(opt.options({ verbose: opt.boolean() }))
.command(
'remote',
(remote) =>
remote
.command('add', addParser, ({ values }) => {
// values.verbose is typed! (from parent globals)
})
.command('remove', removeParser, removeHandler)
.defaultCommand('list'),
'Manage remotes',
)
.parseAsync();
The factory function receives a CliBuilder that already carries the parent's global types. This allows nested handlers to see merged parent globals + command options types at compile time.
At runtime, parent globals flow through the parentGlobals state:
parentGlobals is passed along{ ...parentGlobals.values, ...commandValues }Both parent command groups and individual subcommands support aliases:
.command(
'remote',
(remote) => remote
.command('remove', parser, handler, { aliases: ['rm', 'del'] }),
{ aliases: ['r'], description: 'Manage remotes' },
)
// All equivalent: git remote remove, git r remove, git remote rm, git r rm
interface Parser<V, P> {
readonly __brand: 'Parser'; // Runtime discrimination
// ...
}
const isParser = (x: unknown): x is Parser<unknown, readonly unknown[]> =>
x !== null &&
typeof x === 'object' &&
'__brand' in x &&
x.__brand === 'Parser';
Using __brand instead of classes enables:
instanceofenum: <const T extends readonly string[]>(
choices: T,
props?: ...
): EnumOption<T[number]>
The const modifier preserves literal types without requiring as const at call sites:
opt.enum(['low', 'medium', 'high']);
// Inferred: EnumOption<'low' | 'medium' | 'high'>
// Not: EnumOption<string>
// pos.positionals() has overloads for 1-4 arguments
positionals<A>(a: A): CallablePositionalsParser<readonly [InferPositional<A>]>;
positionals<A, B>(a: A, b: B): CallablePositionalsParser<readonly [InferPositional<A>, InferPositional<B>]>;
// ...
Overloads ensure positional types are inferred as tuples ([string, number]) rather than arrays ((string | number)[]).
type InferOption<T extends OptionDef> =
T extends BooleanOption
? T['required'] extends true
? boolean
: T['default'] extends boolean
? boolean
: boolean | undefined
: // ... other option types
Conditional types inspect the option definition to determine:
| undefined) based on required and defaultbargs provides both .parse() and .parseAsync():
// Sync - throws if transform/handler returns a Promise
const result = cli.parse();
// Async - supports async transforms and handlers
const result = await cli.parseAsync();
Why both?
await.parse() throws immediately if async is accidentally introduced, catching bugs earlyThe implementation checks for thenables at each step and throws a descriptive error if async is detected in sync mode.