Common pitfalls
Optique's combinators compose in ways that are usually intuitive, but a handful of behaviors trip up almost everyone at least once. Most of the confusion traces back to a few core ideas:
- Combinators describe a grammar, not a checklist.
or(),object(), and friends decide which tokens to consume based on consumption and priority, not on the order you wrote them. - Parsing happens in two stages. Structural parsing (which tokens go where) runs before value parsing (turning strings into typed values).
- “Optional” is something you add, not a default. Most parsers are required until you wrap them.
- Fallback values come from a resolution chain, not just the command line. Environment variables, config files, and derived defaults all feed into it, but only when you wire them up.
This page collects the mistakes and misconceptions that follow from those ideas, with the correct mental model for each.
or() is not “optional alternatives”
or(a, b) means exactly one of a or b must match, not zero or more of them may match. If you give no arguments at all, parsing fails because no branch matched.
const mode = or(
option("--server", string()),
option("--client", string()),
);
// With neither option present, this FAILS: at least one branch must match.
const a = parse(mode, []);
// a.success === false
// Wrap the whole or() to make “no selection” a valid outcome.
const b = parse(optional(mode), []);
// b.success === true, b.value === undefinedUse optional(or(...)) when absence should produce undefined, or withDefault(or(...), fallback) when it should produce a fallback value.
Two related surprises:
- Giving two mutually exclusive flags is a conflict, not “last one wins.”
or(option("--mode-a"), option("--mode-b"))rejects--mode-a --mode-bwith a conflict error rather than silently keeping the last one. - A non-consuming branch can act as a fallback.
or(constant("auto"), option("-o", string()))succeeds on empty input (yielding"auto") and still prefers the-obranch when it is present. This works becauseconstant()succeeds without consuming any tokens. Note that annotation-backed parsers such asbindEnv(option(...))and positionalargument(...)parsers are not eligible as such fallback branches; give those a default through their own binding instead.
See construct combinators for the full semantics.
optional() versus withDefault()
Both make a parser non-required, but they differ in a way that matters:
optional(p)yieldsundefinedwhenpdoes not match. You can tell “the user did not provide this” from “the user provided a value.”withDefault(p, value)yieldsvaluewhenpdoes not match. You cannot distinguish a supplied value from the default, because both look the same.
const a = object({ cwd: optional(option("--cwd", string())) });
type A = InferValue<typeof a>; // { readonly cwd: string | undefined }
const b = object({ cwd: withDefault(option("--cwd", string()), ".") });
type B = InferValue<typeof b>; // { readonly cwd: string }Reach for optional() when the absence of a value carries meaning (for example, “fall back to the current directory only if --cwd was not given”). Reach for withDefault() when you only need a usable value. Combining the two (withDefault(optional(...), ...)) is redundant; drop one.
NOTE
withDefault() resolves eagerly, during parsing. The default is not guaranteed to win just because the command line omitted the option: if the wrapped parser resolves a value during completion (from constant(), bindEnv(), or bindConfig()), that value wins over the configured default. When a fallback must be computed lazily, depend on another parsed value, or run only inside a handler, use @optique/derived-defaults or deferredValue() instead.
See modifying combinators for details.
What is required by default
Newcomers often assume options are optional by nature and flags are always present-or-absent. Neither is true.
option()andargument()that take a value are required by default. Parsing fails if they are missing. Wrap them withoptional()orwithDefault()to make them non-required.flag("--x")is required: parsing fails if the flag is absent.option("--x")without a value parser is a Boolean that defaults tofalsewhen absent and never fails on its own.
const parser = object({
// Absent on the command line: this is `false`, and parsing still succeeds.
verbose: option("-v", "--verbose"),
// Absent on the command line: parsing FAILS, because flag() is required.
force: flag("-f", "--force"),
});This also explains a common Boolean trap. withDefault(option("--watch"), true) can never be set to false, because the only way to change a value-less option is to pass it, which produces true. When you need an explicit override in both directions, use negatableFlag(), which pairs a positive and a negative name:
const color = withDefault(
negatableFlag({ positive: "--color", negative: "--no-color" }),
() => detectColorSupport(),
{ message: message`auto` },
);See primitive parsers.
Parsing order is free, not positional
object() and tuple() attempt their members in priority order (commands before options and arguments, and so on), independent of the order you wrote them. Declaration order affects only how options appear in help text, not how input is matched. As a result, options may appear in any order on the command line, which is what most CLIs want.
When the grammar genuinely is sequential, for example a positional argument that must come before a subcommand, use seq():
const parser = seq(
argument(string({ metavar: "PATH" })),
command("build", constant("build")),
);WARNING
seq() does not backtrack. If you place a variadic positional parser (such as multiple(argument(...))) before a command, it can consume the tokens the command needed before the command ever gets a chance to match. Keep the prefix fixed, or add a clear boundary between the segments.
A subtler consequence of two-stage parsing: or() over positional arguments cannot choose a branch based on which value parser succeeds. Structural parsing decides which branch consumes the positional before any value parser runs, so in or(argument(path()), argument(url())) the second branch is unreachable. Combine the value parsers into one argument() instead, using firstOf() (which tries each in order, so put the more specific parser first):
// Tries integer() first; falls back to string() for everything else.
const target = argument(firstOf(integer(), string()));firstOf() is synchronous-only and order-sensitive: a leading string() would shadow every later branch, because it accepts any input.
multiple() never fails on its own
multiple() defaults to min: 0, so when nothing matches it returns an empty array rather than failing. You do not need to null-check the result.
The flip side surprises people: withDefault(multiple(p), fallback) does not fall back on empty input, because multiple() already succeeded with []. The default branch is never taken.
// No --tag given: succeeds with [], NOT with ["latest"].
const tags = withDefault(multiple(option("--tag", string())), ["latest"]);
// Require at least one occurrence so an empty input is a genuine non-match:
const required = multiple(option("--tag", string()), { min: 1 });If you want “use this default when the user supplied nothing,” set min: 1 on the inner multiple(), or post-process with map(multiple(p), xs => xs.length ? xs : fallback).
Synchronous versus asynchronous parsing
Asynchrony is contagious. If any value parser in a parser tree is asynchronous (for example gitBranch() from @optique/git), the whole parser becomes asynchronous, and run() and parse() return a Promise.
- Use
runAsync()andparseAsync()with asynchronous parsers. runSync()andparseSync()reject asynchronous parsers (a type error, and aTypeErrorat runtime); they exist for the cases where you know the parser is synchronous and want a non-Promisereturn.- Passing
contextstorun()makes it asynchronous, becauserun()selects the async two-pass path:run(syncParser, { contexts })returns aPromiseeven though the parser itself is synchronous. If the parser and the contexts are all synchronous,runSync()also acceptscontexts(it delegates to the synchronous two-pass path) and stays synchronous.
// Asynchronous parser, or contexts via run()/runAsync():
const config = await runAsync(parser, { contexts: [envContext] });Fallback sources: environment, config, and derived defaults
Optique offers several ways for a value to come from somewhere other than the command line, and they are easy to conflate. They serve different purposes:
| Mechanism | Package | What it does | Field type |
|---|---|---|---|
dependency() | @optique/core/dependency | Makes one option's set of valid values depend on another's value | plain value |
| Derived defaults | @optique/derived-defaults | Computes a default value from values already parsed (second pass) | plain value |
| Config files | @optique/config | Supplies a fallback from a configuration file | plain value |
| Environment variables | @optique/env | Supplies a fallback from an environment variable | plain value |
deferredValue() | @optique/core/modifiers | Defers resolution to the handler (prompt, network, conditional) | a function |
The single most common mistake here is forgetting to register the context:
WARNING
bindEnv(), bindConfig(), and bindDerivedDefault() do nothing unless their context is passed to the runner through the contexts option. Omitting it causes the parser to fall back to a static default if one exists, or otherwise fail as a missing value. The binding itself is silent about the omission.
// Without this, bindEnv()/bindConfig()/bindDerivedDefault() are no-ops:
await runAsync(parser, { contexts: [envContext] });
// ^^^^^^^^^^^^^^^^^^^^^^^^ requiredWhen several sources apply to the same option, the documented resolution chain is CLI argument > environment variable > config file > static default. The order is determined by how you nest the bindings: an outer wrapper overrides an inner one. Derived defaults slot in wherever you wrap them in that chain.
Two more points worth knowing:
- Fallback values are re-validated. A config or environment value still passes through the inner parser's constraints. A config
portof80is rejected byoption("--port", integer({ min: 1024 }))exactly as a command-line80would be. - For config-only or environment-only values, wrap
fail(), notconstant().constant()always succeeds, sobindConfig()treats it as a value the user supplied and skips the file lookup entirely.fail()always fails, which is what lets the fallback take over.
Value parser surprises
integer()accepts decimal only. Hexadecimal (0x10), binary (0b10), octal (0o10), and scientific notation (1e3) are all rejected. For values beyondNumber.MAX_SAFE_INTEGER, useinteger({ type: "bigint" }).- Negative numbers and
--. A value parser handles-5fine when it follows its option (--offset -5). But a positional token that begins with-looks like an option, so use the--separator to mark the end of options:myapp -- -5. - Bundled short flags are Boolean-only.
-abcexpands to-a -b -conly for value-less flags. An option that takes a value cannot be bundled;-p8080is not-p 8080. - Boolean options reject
=value.--verbose=truefails for a value-lessoption("--verbose"). The=valueform is for options that take a value. choice()matches exactly. It is case-sensitive and whitespace-sensitive by default; pass{ caseInsensitive: true }to relax case (string choices only). It infers a literal union type automatically, so you do not needas const.
const big = integer({ type: "bigint" });
const level = choice(["debug", "info", "warn", "error"]);
// level accepts "debug" | "info" | "warn" | "error"; no `as const` needed.See value parsers.
Type-level limits
- Combinator arity is bounded.
or(),merge(),concat(), andlongestMatch()infer precise types up to a fixed number of arguments (currently 15 foror()). Beyond that the compiler reports an explicit error; split the branches into nested groups so each call stays within the limit. InferValue<T>is usually unnecessary. TypeScript infers the result type of a parser automatically. Annotating withInferValue<typeof parser>everywhere tends to add noise without adding safety; reach for it only when you need to name the type explicitly.
run() exits the process
The run() from @optique/run is built for applications, not libraries. On a parse error, or for --help and --version, it prints output and calls process.exit(). It does not return a result you inspect for success.
If you need to handle the result yourself (for tests, or for embedding a parser in a larger program), use the lower-level parse() (or parseSync() / parseAsync()) from @optique/core, which return a Result object you can inspect for success. The mid-level runParser() behaves more like run(): it returns the parsed value directly, invokes onHelp / onError callbacks, or throws a RunParserError by default, rather than returning a Result. During testing you can also pass an onExit callback to run() to intercept the exit.
Other sharp edges
Trailing arguments meant for another tool.
multiple(argument(...))will not capture tokens that look like options (anything starting with-). To forward arbitrary trailing arguments to a sub-tool, usepassThrough(). Its default format is"equalsOnly", which captures only--opt=value; use{ format: "greedy" }to capture every remaining token.constparser=object({script:argument(string({metavar: "SCRIPT" })),rest:passThrough({format: "greedy" }), });Descriptions are
Messageobjects, not plain strings. They go in the trailing options object, built with themessagetemplate tag.consthost=option("--host",string(), {description:message`The server host name.`, });map()must not mutate its input. During deferred resolution, object and array values can be shared placeholder references, so an in-place mutation corrupts later parses. Always return a new value.Built-in
--help,--version, andcompletionare intercepted only when your parser leaves those tokens unconsumed. If your own grammar accepts the same token (for example a positional that happens to behelp), your parse result wins and the runner does not intercept it.Command discovery is a runtime feature.
runProgram({ dir })from @optique/discover imports command modules dynamically at startup, so bundlers cannot see them. If you rely on tree shaking, static bundling, or single-file executables, pass the command modules explicitly instead.