Skip to content

Type-safe CLI parser combinators

Make impossible CLI states unrepresentable.

Model your command-line grammar with composable parsers. Mutually exclusive modes, dependent options, inferred result types, and context-aware completion all come from the same structure.

The parser is the rule
const 
auth
=
object
({
mode
:
constant
("auth"),
token
:
option
("--token",
string
()),
key
:
option
("--key",
string
()),
}); const
config
=
object
({
mode
:
constant
("config"),
host
:
option
("--host",
firstOf
(
ip
(),
hostname
())),
port
:
option
("--port",
port
()),
}); const
deploy
=
or
(
auth
,
config
);
Inferred type
type Deploy =
  | { mode: "auth"; token: string; key: string }
  | { mode: "config"; host: string; port: number };
npm add @optique/core @optique/run
pnpm add @optique/core @optique/run
yarn add @optique/core @optique/run
deno add --jsr @optique/core @optique/run
bun add @optique/core @optique/run
Runs onNode.jsDenoBun
@optique/core also inBrowsersEdge

See it refuse

Don't take our word for it.

This is the exact deploy parser from above, not a mockup. Mix the two modes, pass an out-of-range port, or drop a required option, and it refuses each one at runtime with the same typed errors your users would see. (It runs right here because @optique/core is dependency-free ECMAScript.)

$ myapp

{ mode: "config", host: "10.0.0.1", port: 8080 }

Why it refuses invalid input

Not a friendlier option builder. A parser-combinator library whose result types are the grammar of your command line.

One parser, every surface

Define it once. Optique refracts the rest.

The same definition drives argument parsing, type inference, help text, shell completion, and Unix man pages. Add an option and every surface follows, with nothing to keep in sync by hand.

const 
host
=
firstOf
(
ip
(),
hostname
(), {
metavar
: "HOST",
}); const
parser
=
object
({
host
:
option
("--host",
host
, {
description
:
message
`Host to bind to.`,
}),
port
:
option
("--port",
port
(), {
description
:
message
`Port to listen on.`,
}), });
// hover in your editor; no annotations needed
const config: {
  readonly host: string;
  readonly port: number;
}

How every surface is generated

Diagnostics

Your errors aren't strings.

Every error and help line is a structured Message composed from semantic parts (option names, values, metavars, env vars, command examples, even URLs), not concatenated text. Optique styles them in a terminal and falls back to plain, quoted text when output is piped or running in CI. You compose your own errors and option descriptions the same way.

const 
input
= "99999";
// The same components compose error messages and help text: const
error
=
message
`Expected ${
metavar
("PORT")} for ${
optionName
("--port")}, but got ${
input
}.`;
In a terminal

Expected PORT for --port, but got 99999.

Piped, or in CI

Expected `PORT` for `--port`, but got "99999".

Composing your own messages

Composition

Parsers are values you can share and transform.

Build small option groups once, then merge() and or() them into larger commands without losing a single type. The same pieces compose across every tool you ship.

const 
CommonOptions
=
object
({
verbose
:
option
("--verbose"),
config
:
option
("--config",
string
()),
}); const
DeployOptions
=
object
({
environment
:
option
("--env",
string
()),
}); // One value, fully typed. Share these groups across every command. const
deployParser
=
merge
(
CommonOptions
,
DeployOptions
);

Construct combinators in depth

Command structure

Subcommands become a union you can narrow.

Branch between subcommands with or(), and each command() becomes one arm of a discriminated union. TypeScript narrows every branch by its tag, so a handler only ever sees the options that subcommand actually defines.

const 
cli
=
or
(
command
("create",
object
({
action
:
constant
("create"),
name
:
argument
(
string
()),
role
:
option
("--role",
string
()),
})),
command
("list",
object
({
action
:
constant
("list"),
limit
:
withDefault
(
option
("--limit",
integer
()), 10),
})),
command
("delete",
object
({
action
:
constant
("delete"),
id
:
argument
(
integer
()),
force
:
option
("--force"),
})), ); type
Command
=
InferValue
<typeof
cli
>;
$ myappor()
  • create{ action: "create"; name; role }
  • list{ action: "list"; limit }
  • delete{ action: "delete"; id; force }
one discriminated union, narrowed by action

Subcommands with command()

Inter-option dependencies

Options can depend on values, not just presence.

When one option's valid values depend on another, Optique resolves that relationship after parsing, then uses the very same graph to power context-aware completion. The values Tab offers are the values your parser will accept.

const 
mode
=
dependency
(
choice
(["dev", "prod"] as
const
),
); const
logLevel
=
mode
.
derive
({
metavar
: "LEVEL",
mode
: "sync",
factory
: (
m
) =>
m
=== "dev"
?
choice
(["debug", "info", "warn", "error"])
:
choice
(["warn", "error"]),
defaultValue
: () => "dev" as
const
,
}); const
parser
=
object
({
mode
:
option
("--mode",
mode
),
logLevel
:
option
("--log-level",
logLevel
),
});
completion
$ app --mode dev --log-level debug · info · warn · error$ app --mode prod --log-level warn · error# suggestions follow the same dependency# Optique uses to validate.

How dependencies resolve

Shell completion

Completion your users install in one line.

The completion script comes from the same parser, so suggestions never drift from what the CLI accepts. It is context-aware: subcommands, options, and even values that depend on other options.

myapp completion bash >> ~/.bashrc
myapp completion zsh > ~/.zsh/completions/_myapp
myapp completion fish > ~/.config/fish/completions/myapp.fish
myapp completion pwsh > myapp-completion.ps1
myapp completion nu | save myapp-completion.nu

The shell completion guide

Every value, one model

The same parser for CLI, environment, config, and prompts.

Integration packages are parser wrappers. Stack them and the priority is just the wrapping order: CLI over environment over config over an interactive prompt.

const 
envContext
=
createEnvContext
({
prefix
: "MYAPP_" });
const
configContext
=
createConfigContext
({
schema
:
z
.
object
({
host
:
z
.
string
().
optional
() }),
}); // CLI > env > config > interactive prompt const
host
=
prompt
(
bindEnv
(
bindConfig
(
option
("--host",
string
()), {
context
:
configContext
,
key
: "host",
}), {
context
:
envContext
,
key
: "HOST",
parser
:
string
() },
), {
type
: "input",
message
: "Host:",
default
: "localhost" },
);

How integration packages stack

Batteries included

Reach for a parser before you write one.

Fifty built-in value parsers, from integer() and ip() to schema validators, Temporal dates, and async Git refs, plus the combinators that assemble them. Every one returns an ordinary parser that composes with the rest.

The value parser reference

Already using another parser?

See exactly how Optique compares.

Nine side-by-side comparisons, each honest about where the other library is the better choice, with working code on both sides.

Start with the overview