-
Notifications
You must be signed in to change notification settings - Fork 6
Description
When building CLIs with options that depend on other option values, the current approach using or() becomes awkward. A common example is a --reporter option where some reporters require an --output-file argument while others do not.
Currently, you would write something like this:
const fileReporterOptions = merge(
baseOptions,
object({
reporter: option("--reporter", choice(["junit", "html", "json"])),
outputFile: option("--output-file", string()),
}),
);
const consoleReporterOptions = merge(
baseOptions,
object({
reporter: withDefault(
optional(option("--reporter", choice(["console"]))),
"console" as const,
),
}),
);
const parser = or(fileReporterOptions, consoleReporterOptions);This approach has several drawbacks. The choice() value parser is split across two separate parsers, so help text cannot show all valid reporter values in one place. When a user runs --reporter html without --output-file, the first parser fails due to the missing option, then falls back to the second parser, which fails because html is not in choice(["console"]). The resulting error message is confusing because it does not explain that --output-file is required for non-console reporters. Additionally, the code requires runtime type narrowing with "outputFile" in options to distinguish between the two cases.
This pattern of “option B is required depending on the value of option A” is a discriminated union at the CLI level. Optique's separation between value parsers and combinators makes this difficult to express cleanly, since the discriminator (--reporter value) is handled by a value parser, but the conditional requirement spans multiple options and belongs at the combinator level.
A dedicated conditional() combinator could address this directly:
const parser = conditional(
option("--reporter", choice(["console", "junit", "html", "json"])),
{
console: object({}),
junit: object({ outputFile: option("--output-file", string()) }),
html: object({ outputFile: option("--output-file", string()) }),
json: object({ outputFile: option("--output-file", string()) }),
},
object({}) // default branch when --reporter is omitted
);With this API, the discriminator option uses a single choice() containing all valid values, so help text remains coherent. Error messages can be context-aware, such as telling the user that --output-file is required when --reporter is junit. The result type is a proper discriminated union that TypeScript can narrow based on the reporter field, and there is no need for backtracking through or() branches.
The combinator would parse the discriminator option first, then select and apply the corresponding branch parser, merging the results. If the discriminator is optional and not provided, it would use the default branch if specified.
This came up in a real-world use case: fedimod/fires#237 (comment)