Inter-option dependencies
This API is available since Optique 0.10.0.
Sometimes the valid values for one command-line option depend on the value of another option. For example, a --log-level option might accept different values depending on whether --mode is set to dev or prod. Optique's dependency system provides type-safe support for these inter-option relationships.
The dependency system works by deferring the final validation of dependent options until all options have been parsed. During parsing, dependent options record their raw input and preliminary result in a shared input trace. After parsing, Optique builds a shared dependency runtime, resolves dependency source values, and replays dependent parsers with the actual dependency values.
Creating a dependency source
To create a dependency relationship, first wrap an existing value parser with dependency() to create a dependency source. A dependency source is a value parser that can be referenced by other parsers:
import { dependency } from "@optique/core/dependency";
import { choice } from "@optique/core/valueparser";
// Create a dependency source from a choice parser
const modeParser = dependency(choice(["dev", "prod"] as const));The dependency() function returns a DependencySource that behaves exactly like the wrapped parser but can be used to create derived parsers.
Creating a derived parser
Once you have a dependency source, use its derive() method to create a derived parser. The derived parser's behavior depends on the source's value:
// Create a derived parser that depends on the mode
const logLevelParser = modeParser.derive({
metavar: "LEVEL",
mode: "sync",
factory: (mode) =>
choice(
mode === "dev"
? ["debug", "info", "warn", "error"]
: ["warn", "error"]
),
defaultValue: () => "dev" as const,
});The derive() method takes an options object with four properties:
metavar- The metavariable name shown in help text (e.g.,
"LEVEL"). mode- The mode of the parser returned by the factory:
"sync"or"async". This determines whether the derived parser is synchronous or asynchronous, without calling the factory at construction time. factory- A function that receives the dependency's value and returns a value parser. This function is called during dependency resolution with the actual dependency value.
defaultValue- A function that returns the default value to use when the dependency is not provided. This allows the derived parser to work even when the dependency option is omitted.
Async factory support
The factory function can return either a sync or async value parser. When the factory returns an async parser, the resulting derived parser will also be async:
import { dependency } from "@optique/core/dependency";
import { string } from "@optique/core/valueparser";
const remoteParser = dependency(string({ metavar: "REMOTE" }));
// Factory returns an async parser - derived parser is also async
const branchParser = remoteParser.derive({
metavar: "BRANCH",
mode: "async",
factory: (remote) => gitRemoteBranch({ remote }),
defaultValue: () => "origin",
});
// branchParser.mode is "async"For explicit control over the factory mode, use deriveSync() or deriveAsync() instead of derive():
import { dependency } from "@optique/core/dependency";
import { choice, string } from "@optique/core/valueparser";
const modeParser = dependency(choice(["dev", "prod"] as const));
// Explicitly sync factory
const logLevelParser = modeParser.deriveSync({
metavar: "LEVEL",
factory: (mode) =>
choice(mode === "dev"
? ["debug", "info", "warn", "error"]
: ["warn", "error"]),
defaultValue: () => "dev" as const,
});The mode of the resulting derived parser is determined by combining the source parser's mode and the factory's return mode:
| Source mode | Factory returns | Result mode |
|---|---|---|
| sync | sync parser | sync |
| sync | async parser | async |
| async | sync parser | async |
| async | async parser | async |
Using dependencies in parsers
Use the dependency source and derived parser as regular value parsers in your option definitions:
const parser = object({
mode: option("--mode", modeParser),
logLevel: option("--log-level", logLevelParser),
});
// In dev mode, debug and info are valid
const result1 = parseSync(parser, ["--mode", "dev", "--log-level", "debug"]);
// result1.value = { mode: "dev", logLevel: "debug" }
// In prod mode, only warn and error are valid
const result2 = parseSync(parser, ["--mode", "prod", "--log-level", "warn"]);
// result2.value = { mode: "prod", logLevel: "warn" }This replay happens automatically during normal parse*() and suggest*() flows, whether the parsers appear at the top level or inside combinators like object(), tuple(), merge(), and concat(). You do not need any special handling beyond using the dependency source and derived parser together.
Dependencies also work across parser combinators like merge() and concat(). For example, you can have the dependency source in one object() and the derived parser in another, then combine them with merge():
// Dependency source and derived parser in separate objects
const parser = merge(
object({ mode: option("--mode", modeParser) }),
object({
logLevel: option("--log-level", logLevelParser),
name: option("--name", string()),
}),
);
// Dependencies are resolved across merged objects
const result = parseSync(parser, [
"--mode", "prod",
"--log-level", "warn",
"--name", "app"
]);
// result.value = { mode: "prod", logLevel: "warn", name: "app" }Option ordering independence
The dependency system handles options in any order. Even if the dependent option appears before its dependency on the command line, the resolution works correctly:
// --log-level appears before --mode, but resolution still works
const result = parseSync(parser, [
"--log-level", "error",
"--mode", "prod"
]);
// result.value = { mode: "prod", logLevel: "error" }Default value behavior
When the dependency option is not provided, the derived parser uses its defaultValue function to determine the dependency value:
// Without --mode, defaultValue() returns "dev"
// So "debug" is valid (it's in the dev mode choices)
const result = parseSync(parser, ["--log-level", "debug"]);
// result.value = { mode: undefined, logLevel: "debug" }Multiple dependencies with deriveFrom()
For parsers that depend on multiple options, use the deriveFrom() function instead of the derive() method:
import { dependency, deriveFrom } from "@optique/core/dependency";
import { object } from "@optique/core/constructs";
import { option } from "@optique/core/primitives";
import { choice, string } from "@optique/core/valueparser";
// Create multiple dependency sources
const envParser = dependency(choice(["local", "staging", "production"] as const));
const regionParser = dependency(choice(["us", "eu", "asia"] as const));
// Create a parser that depends on both
const serverParser = deriveFrom({
metavar: "SERVER",
mode: "sync",
dependencies: [envParser, regionParser] as const,
factory: (env, region) => {
// Generate valid servers based on both environment and region
const servers = [];
if (env === "local") {
servers.push("localhost");
} else {
servers.push(`${env}-${region}-1`, `${env}-${region}-2`);
}
return choice(servers);
},
defaultValues: () => ["local", "us"] as const,
});
const parser = object({
env: option("--env", envParser),
region: option("--region", regionParser),
server: option("--server", serverParser),
});Like derive(), deriveFrom() also supports async factories. Use deriveFromSync() or deriveFromAsync() for explicit mode control:
import { dependency, deriveFromSync } from "@optique/core/dependency";
import { choice } from "@optique/core/valueparser";
const envParser = dependency(choice(["local", "staging", "production"] as const));
const regionParser = dependency(choice(["us", "eu", "asia"] as const));
// Explicitly sync factory
const serverParser = deriveFromSync({
metavar: "SERVER",
dependencies: [envParser, regionParser] as const,
factory: (env, region) =>
choice(env === "local"
? ["localhost"]
: [`${env}-${region}-1`, `${env}-${region}-2`]),
defaultValues: () => ["local", "us"] as const,
});Chaining derived dependency sources
This behavior is available since Optique 1.3.0.
Wrap a derived parser with dependency() when later parsers need to depend on its resolved value. The result keeps both roles: it remains derived from its upstream source and becomes a source for the next level:
import { object } from "@optique/core/constructs";
import { dependency } from "@optique/core/dependency";
import { parseSync } from "@optique/core/parser";
import { option } from "@optique/core/primitives";
import { choice } from "@optique/core/valueparser";
const frameworkParser = dependency(choice(["fresh", "hono"] as const));
const packageManagerParser = dependency(frameworkParser.deriveSync({
metavar: "PACKAGE_MANAGER",
factory: (framework) =>
choice(framework === "fresh" ? ["deno"] : ["npm"]),
defaultValue: () => "fresh" as const,
}));
const storageParser = packageManagerParser.deriveSync({
metavar: "STORAGE",
factory: (packageManager) =>
choice(packageManager === "deno" ? ["kv"] : ["redis"]),
defaultValue: () => "deno" as const,
});
const parser = object({
// Field order does not determine dependency evaluation order.
storage: option("--storage", storageParser),
packageManager: option("--package-manager", packageManagerParser),
framework: option("--framework", frameworkParser),
});
const result = parseSync(parser, [
"--framework", "hono",
"--package-manager", "npm",
"--storage", "redis",
]);The same composition works when the middle parser was created with deriveFrom() and therefore has several upstream sources. Synchronous and asynchronous modes continue to combine at each level, so one asynchronous source or factory makes every downstream parser asynchronous.
Optique resolves a chain in dependency order rather than object/tuple field order. A derived source publishes only its replayed value; its preliminary parse result, which may have used an upstream default, is never exposed to the next level. If an intermediate source is absent without failing, a downstream parser may use its own defaultValue/defaultValues. If an upstream value was provided but failed validation, that failure propagates instead, and the error includes the affected metavar chain.
Shell completion support
The dependency system integrates with Optique's shell completion. When generating completions for a derived parser, the system is context-aware:
- If the dependency option has already been specified on the command line, completions are generated based on that actual value.
- If the dependency option hasn't been specified yet, the system uses the
defaultValueto generate reasonable suggestions.
This means users get accurate completions that reflect the current state of their command line:
// With --mode prod already specified, completions show prod ports
const suggestions = await suggestAsync(parser, ["--mode", "prod", "--port", ""]);
// suggestions include "80" and "443" (prod mode ports)
// Without --mode, completions use defaultValue ("dev")
const defaultSuggestions = await suggestAsync(parser, ["--port", ""]);
// suggestions include "3000" and "8080" (dev mode ports)Practical example: Git-like CLI
Here's a more realistic example showing how dependencies can be used in a Git-like CLI where the valid branches depend on the remote:
import { dependency } from "@optique/core/dependency";
import { object } from "@optique/core/constructs";
import { option, argument } from "@optique/core/primitives";
import { choice, string } from "@optique/core/valueparser";
// Remote is a dependency source
const remoteParser = dependency(choice(fetchRemotes()));
// Branch depends on which remote is selected
const branchParser = remoteParser.derive({
metavar: "BRANCH",
mode: "sync",
factory: (remote) => choice(fetchBranches(remote)),
defaultValue: () => "origin",
});
const pushCommand = object({
remote: argument(remoteParser),
branch: argument(branchParser),
force: option("-f", "--force"),
});Interactive sources
This behavior is available since Optique 1.3.0.
A dependency source can be wrapped in a prompt fallback such as prompt() from @optique/inquirer or @optique/clack. When the command line omits the source option and the prompt supplies the value interactively, the prompted value registers as the dependency value, so derived parsers observe the value the user actually selected:
import { object } from "@optique/core/constructs";
import { dependency } from "@optique/core/dependency";
import { option } from "@optique/core/primitives";
import { choice } from "@optique/core/valueparser";
import { prompt } from "@optique/inquirer";
const modeParser = dependency(choice(["dev", "prod"] as const));
const portParser = modeParser.derive({
metavar: "PORT",
mode: "sync",
factory: (mode) =>
choice(mode === "dev" ? ["3000", "8080"] : ["80", "443"]),
defaultValue: () => "dev" as const,
});
const parser = object({
mode: prompt(option("--mode", modeParser), {
type: "select",
message: "Select mode:",
choices: ["dev", "prod"],
}),
port: option("--port", portParser),
});
// With --port 443 and no --mode, the prompt asks for the mode first, and
// the answer determines which ports --port accepts.A derived parser behaves identically whether the dependency value came from the command line, an environment or configuration binding, or a prompt. Existing precedence is unchanged: the prompt runs only after the command line and earlier fallback sources fail to produce a value.
A prompted source and its consumers can live anywhere within the same object(), tuple(), seq(), concat(), or merge() composition, including fields contributed through merge() children and sources nested in child constructs such as concat() child tuples. A prompted source transformed with map() registers its pre-transform value, so consumers derive from the value the prompt produced rather than the mapped result. To make its value available before derived parsers re-evaluate, a dependency-source prompt runs before ordinary prompts, so it may be displayed before a non-source prompt declared earlier in the same object. When the user cancels a source prompt, the parse fails immediately and later prompts do not run.
The relationship also works in the opposite direction: a prompt's own configuration can be derived from dependency values with derivePromptConfig() from @optique/prompt (re-exported by @optique/inquirer and @optique/clack). The resolver receives the published source values and produces the prompt configuration right before the prompt runs, so a later question can adapt its choices to earlier answers:
import { object } from "@optique/core/constructs";
import { dependency } from "@optique/core/dependency";
import { option } from "@optique/core/primitives";
import { choice } from "@optique/core/valueparser";
import { derivePromptConfig, prompt } from "@optique/inquirer";
const framework = dependency(choice(["fresh", "hono"] as const));
const packageManager = dependency(framework.deriveSync({
metavar: "PACKAGE_MANAGER",
factory: (value) =>
choice(value === "fresh" ? ["deno"] : ["npm", "pnpm"]),
defaultValue: () => "fresh" as const,
}));
const storage = packageManager.deriveSync({
metavar: "STORAGE",
factory: (pm) => choice(pm === "deno" ? ["kv"] : ["redis", "postgres"]),
defaultValue: () => "deno" as const,
});
const parser = object({
framework: prompt(option("--framework", framework), {
type: "select",
message: "Web framework:",
choices: ["fresh", "hono"],
}),
packageManager: prompt(
option("--package-manager", packageManager),
derivePromptConfig(framework, (value) => ({
type: "select",
message: "Package manager:",
choices: value === "fresh" ? ["deno"] : ["npm", "pnpm"],
})),
),
storage: option("--storage", storage),
});
// The package manager prompt derives its choices from the framework
// answer. The same answer constrains --package-manager on the CLI, then
// determines which storages --storage accepts.Such a prompt is a consumer in the dependency graph, and it may be a source at the same time, as above. The scheduler runs it only after the sources its resolver reads have published, whether their values came from the command line, a binding, or another prompt. See the @optique/prompt documentation for resolver defaults, failure behavior, and the runtime condition form.
A source inside a conditional() or a command() also reaches consumers declared next to that construct, and it does so whether the value was typed on the command line or answered interactively: the conditional() discriminator, the fields of the selected branch, and the subtree of a selected command all register into the enclosing runtime. When nothing on the command line selects a branch, the branch chosen by the discriminator's completion—including a prompted discriminator's answer, and the default branch when no named branch applies—is resolved once before derived parsers re-evaluate, and the same selection is reused by the final completion. One limitation: a prompted discriminator that does not wrap a dependency source cannot participate in this early branch resolution, so sources inside the branch it selects only complete during the conditional's own completion. Wrap the discriminator's value parser with dependency() when sibling consumers need those branch sources before dependency replay.
Evaluation order and failures
Dependency evaluation follows the active source graph. Independent nodes keep their declaration order, while each source is resolved before the derived parsers that consume it. This applies equally to object() and tuple() and to sources exposed through selected conditional()/command() branches.
Suggestions use values already present in parser state, plus declared defaultValue/defaultValues fallbacks when a source is absent. They never run prompts, prompt configuration resolvers, or other effectful completions. During real completion, effectful sources run serially and at most once per parse operation. Their results and failures are scoped to that operation.
A prompt configured with derivePromptConfig() is a consumer in this graph, so its sources resolve before the prompt regardless of field order. The same rules apply when a conditional() chooses its branch during completion:
| Situation | Result |
|---|---|
| A selected branch reads a source declared after the conditional | The source resolves before the branch consumer. |
| Only an unselected branch reads an effectful source | The source's prompt or other effect does not run. |
| A speculative branch conflicts with the completed discriminator | The parse fails before effects needed only by the rejected branch run. |
An absent optional() occurrence or unselected nested alternative could provide the source | It does not hide a later occurrence that actually publishes a value. |
| The selected branch publishes a repeated source | Consumers inside the branch read that value; consumers after the conditional read the later outer occurrence. |
A selected route contains only a withDefault() occurrence | The default fills the source only if no awaited occurrence has published it. |
| The command line already committed the branch | The branch joins the enclosing scope and ordinary declaration precedence applies. |
For a repeated source, the branch's publish stays confined to that branch. It serves the branch's consumers without replacing the later outer occurrence seen by consumers after the conditional.
Cycles are checked against the selected branch's actual providers and consumers. Edges from branches that cannot be selected together do not form a cycle.
An invalid source never falls back to its default. The failure propagates through every derived source that consumes it—including prompts whose configurations read it—and diagnostics show the metavars along that dependency path. Optique also rejects a cycle in the active runtime graph with the involved paths/metavars, although ordinary derive() and deriveFrom() composition constructs an acyclic graph by value; derived prompt configurations can introduce cycles, which fail with the same diagnostic. An apparent cycle whose edges belong to conditional() branches that cannot be selected together is not a real cycle and is not rejected: the judgment is made against the selected branch's actual providers and consumers.