Type-safe CLI parser combinators
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.
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);type Deploy =
| { mode: "auth"; token: string; key: string }
| { mode: "config"; host: string; port: number };npm add @optique/core @optique/runpnpm add @optique/core @optique/runyarn add @optique/core @optique/rundeno add --jsr @optique/core @optique/runbun add @optique/core @optique/runSee it refuse
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.)
✓{ mode: "config", host: "10.0.0.1", port: 8080 }
Not a friendlier option builder. A parser-combinator library whose result types are the grammar of your command line.
One parser, every surface
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;
}Diagnostics
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}.`;Expected for --port, but got 99999.
Expected `PORT` for `--port`, but got "99999".
Composition
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);Command structure
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(){ action: "create"; name; role }{ action: "list"; limit }{ action: "delete"; id; force }actionInter-option dependencies
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),
});$ 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.
Shell completion
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 >> ~/.bashrcmyapp completion zsh > ~/.zsh/completions/_myappmyapp completion fish > ~/.config/fish/completions/myapp.fishmyapp completion pwsh > myapp-completion.ps1myapp completion nu | save myapp-completion.nuEvery value, one model
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" },
);From the cookbook
Mutually exclusive modes, options that gate others, key–value pairs, pass-through, verbosity, negatable flags. Each hard requirement is a small composition, and each one has a recipe in the cookbook.
or()constant()Options that apply only with a flagDependent options enter the type only when their gate flag is set.merge()withDefault()Valid values that depend on another optionDerive one option's accepted values from another option's parsed value.dependency()Options chosen by a discriminatorA --reporter-style choice decides which extra options are valid.conditional()Repeatable KEY=VALUE pairsCollect many pairs and map them straight into a typed record.keyValue()multiple()Stacked -v, -vv, -vvv verbosityCount repeated flags and map the total to a level.multiple()map()--color paired with --no-colorOne setting, both directions, with an explicit default.negatableFlag()Forward unknown flags to a wrapped toolCapture unrecognized arguments and pass them through untouched.passThrough()Deprecated flags that still work, but hiddenKeep old names parsing while hiding them from help and completion.hiddenA profile before the subcommandConsume an optional positional before the command, in declaration order.seq()Batteries included
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.
Value parsers 50
…plus any Zod, Valibot, or Standard Schema validator, reused as a value parser.
Combinators 17
The ecosystem
Past @optique/core and @optique/run, every
package is a small, self-contained integration. They group by the role they
play: extra value types to parse, alternative sources a value can come from,
and the surfaces a parser drives. Add only the ones your CLI reaches for.
Foundation
Value parsers
Value sources
Already using another parser?
Nine side-by-side comparisons, each honest about where the other library is the better choice, with working code on both sides.