Optique 1.0.0: environment variables, interactive prompts, and 1.0 API cleanup · dahlia optique · Discussion #796

37 min read Original article ↗

Optique is a type-safe combinatorial CLI parser for TypeScript, inspired by Haskell's optparse-applicative and TypeScript's Zod. It takes a functional approach: you compose small, typed parsers into larger ones using combinators, and the TypeScript compiler infers the result type automatically. It supports flags, options, subcommands, inter-option dependencies, shell completion, and man page generation across Deno, Node.js, and Bun.

This is the first stable release. Optique 1.0.0 adds two integration packages and finishes the 1.0 API cleanup. It also rewrites the source-context and dependency runtime internals and fixes several hundred issues in shell completion, help output, and value parsing.

New packages

@optique/env: environment variable integration

The new @optique/env package lets you bind any parser to an environment variable as a fallback when the CLI argument is absent. The priority chain is CLI argument → environment variable → default value → error.

import { bindEnv, bool, createEnvContext } from "@optique/env";
import { object } from "@optique/core/constructs";
import { option } from "@optique/core/primitives";
import { integer, string } from "@optique/core/valueparser";
import { run } from "@optique/run";

const envContext = createEnvContext({ prefix: "MYAPP_" });

const parser = object({
  host: bindEnv(option("--host", string()), {
    context: envContext,
    key: "HOST",
    default: "localhost",
  }),
  port: bindEnv(option("--port", integer()), {
    context: envContext,
    key: "PORT",
    default: 3000,
  }),
  verbose: bindEnv(option("--verbose", bool()), {
    context: envContext,
    key: "VERBOSE",
    default: false,
  }),
});

await run(parser, { contexts: [envContext] });

The package includes a bool() value parser that accepts all common Boolean environment variable literals: true, false, 1, 0, yes, no, on, off. When used via bindEnv(fail<T>(), ...), it also supports env-only values with no CLI option counterpart.

See the environment variable integration guide for bindEnv(), bool(), and env-only values via fail<T>(). (#86)

@optique/inquirer: interactive prompt integration

The new @optique/inquirer package wraps any parser with an interactive Inquirer.js prompt that fires when no CLI argument is provided. Ten prompt types are supported: input, password, number, confirm, select, rawlist, expand, checkbox, editor, and a prompter escape hatch for custom implementations.

import { prompt } from "@optique/inquirer";
import { object } from "@optique/core/constructs";
import { option } from "@optique/core/primitives";
import { string, integer } from "@optique/core/valueparser";
import { run } from "@optique/run";

const parser = object({
  name: prompt(option("--name", string()), {
    type: "input",
    message: "Enter your name:",
  }),
  port: prompt(option("--port", integer()), {
    type: "number",
    message: "Enter the port number:",
    default: 3000,
  }),
});

await run(parser);

prompt() always returns an async parser (mode: "async"). It integrates cleanly with bindEnv() and bindConfig(): the prompt is skipped whenever the CLI, environment variable, or config file already supplies a value. Cancelling a prompt with Ctrl+C produces a normal parse failure rather than an unhandled rejection. (#87, #151)

See the interactive prompt guide for supported prompt types, configuration options, and bindEnv() / bindConfig() composition examples.

Breaking changes

@optique/core

Parser.$mode and ValueParser.$mode renamed to .mode

The runtime mode property no longer carries a $ prefix. Type-only markers such as Parser.$valueType, Parser.$stateType, and SourceContext.$requiredOptions keep the $ prefix. Update any code that reads .mode off a parser or value parser object.

Narrowed public extension surface (#790, #792, #793, #794)

@optique/core now exposes two public extension subpaths:

  • @optique/core/annotations: read-only annotation access via getAnnotations()
  • @optique/core/extension: wrapper helpers including injectAnnotations(), inheritAnnotations(), withAnnotationView(), dispatchByMode(), mapModeValue(), wrapForMode(), defineTraits(), getTraits(), delegateSuggestNodes(), and mapSourceMetadata()

The old @optique/core/mode-dispatch subpath and previously leaked internal entry points are removed. If you maintained a custom parser or wrapper that imported from internal paths, migrate to the new @optique/core/extension subpath.

Source context phase field now required (#243, #783)

SourceContext previously used an inferred mode contract to decide whether to run a two-pass parse. This contract was ambiguous in edge cases. It is now replaced by an explicit required phase field:

// Before (inferred from getAnnotations() behavior)
const myContext: SourceContext = { id: ..., getAnnotations() { ... } };

// After
const myContext: SourceContext = {
  id: ...,
  phase: "two-pass",  // or "single-pass"
  getAnnotations(request) { ... },
};

createEnvContext() sets phase: "single-pass" and createConfigContext() sets phase: "two-pass". Custom contexts must migrate to the new contract. SourceContextMode, SourceContext.mode, and isStaticContext() have been removed.

SourceContext.getAnnotations() receives an explicit request object (#271, #786)

The two-pass protocol previously used undefined as a phase-1 sentinel, making it impossible for custom contexts to distinguish a genuine first-pass undefined result from “no data yet.” The method signature has changed:

// Before
getAnnotations(): Annotations | undefined;

// After
getAnnotations(request: SourceContextRequest): Annotations;
// where request.phase is "phase1" or "phase2"

If you implemented a custom SourceContext, update getAnnotations() to accept a SourceContextRequest parameter and use request.phase to distinguish the phases.

Context-required options must now be wrapped in contextOptions (#240, #241, #575, #581)

Options required by source contexts (such as getConfigPath and load for @optique/config) must now be passed inside a contextOptions property instead of as top-level runner options. This prevents name collisions with built-in runner options like args, help, and version.

// Before
await run(parser, {
  contexts: [configContext],
  getConfigPath: (parsed) => parsed.config,
});

// After
await run(parser, {
  contexts: [configContext],
  contextOptions: { getConfigPath: (parsed) => parsed.config },
});

ValueParser.placeholder is now a required interface property (#407, #727)

Every ValueParser must now declare a placeholder property: a type-appropriate stand-in value (for example, "" for string(), 1 for port()) used during phase-1 parsing when prompt() defers its answer to phase 2. The isPlaceholderValue() function and the placeholder symbol export from @optique/core/context have been removed alongside the old DeferredPromptValue sentinel.

If you implemented a custom ValueParser, add a placeholder property with a suitable stand-in value of type T. If you used isPlaceholderValue() to detect deferred values, that check is no longer necessary. See the Core improvements section for the rationale behind this change.

valueSet() now requires a fallback parameter (#492, #747)

valueSet() used to accept a single array argument, which could produce malformed sentences like “Expected one of .” when the array was empty. It now requires a second parameter: either a fallback string or an options object with a fallback field.

// Before
valueSet(choices)

// After
valueSet(choices, { fallback: "any value" })
// or
valueSet(choices, "any value")

values() now throws TypeError when given an empty array.

Meta command configuration redesigned (#130)

The help, version, and completion fields in RunOptions (both in @optique/core and @optique/run) previously used a mode: "command" | "option" | "both" discriminant to control whether a meta feature appeared as a subcommand, a flag, or both. This has been replaced with independent command and option sub-configs:

// Before
{ help: { mode: "both", commandNames: ["help"], optionNames: ["--help"] } }

// After
{ help: { command: { names: ["help"] }, option: { names: ["--help"] } } }

String shorthands ("command", "option", "both") are preserved in @optique/run for convenience. The types CompletionName, CompletionHelpVisibility, CompletionConfig, CompletionConfigBoth, CompletionConfigSingular, and CompletionConfigPlural have been removed from @optique/core. The corresponding types from @optique/run (CompletionHelpVisibility, CompletionOptionsBase, CompletionOptionsBoth, CompletionOptionsSingular, CompletionOptionsPlural, CompletionOptions) have also been removed.

UserParserNames interface simplified (#735, #741)

The leadingOptions, leadingCommands, and leadingLiterals fields have been replaced by a single leadingNames set.

Help output section ordering changed (#115)

The default section ordering in help output now uses a type-aware sort: sections containing only commands appear first, followed by mixed sections, and then sections containing only options, flags, and arguments. Untitled sections receive a slight priority boost. Previously, untitled sections were sorted first regardless of content type. A new sectionOrder callback option in DocPageFormatOptions and RunOptions can override this sort entirely.

@optique/config

runWithConfig() removed (#110)

The @optique/config/run subpath and the runWithConfig() function have been removed. Config contexts are now used directly with run(), runSync(), or runAsync() from @optique/run (or runWith() from @optique/core/facade) via the contexts option. Options like getConfigPath and load are now passed via contextOptions.

// Before
import { runWithConfig } from "@optique/config/run";
await runWithConfig(parser, configContext, {
  args: process.argv.slice(2),
  getConfigPath: (parsed) => parsed.config,
});

// After
import { run } from "@optique/run";
await run(parser, {
  contexts: [configContext],
  contextOptions: { getConfigPath: (parsed) => parsed.config },
});

fileParser moved to createConfigContext() options (#110)

The fileParser option, previously passed to runWithConfig() at runtime, must now be provided when calling createConfigContext().

configKey symbol removed (#136)

Each ConfigContext instance now stores its data under its own unique id symbol. If you accessed config annotations directly via the exported configKey symbol, use context.id instead.

CustomLoadOptions.load now returns { config, meta } instead of raw config (#111)

The load callback must now return a ConfigLoadResult object ({ config, meta }) rather than raw config data. This allows config metadata (such as configPath and configDir) to be passed to bindConfig() key callbacks via the new second meta parameter.

bindConfig() now validates fallback values against parser constraints (#414, #777)

Config-file values and defaults now go through the inner parser's validateValue() method when available. If your config file contains values that violate CLI parser constraints (for example, an integer below a min threshold), those values are now rejected rather than silently accepted. This is a behavior change if you relied on config values bypassing CLI validation.

@optique/run

contextOptions wrapping required (#240, #241, #575, #581)

As with runWith(), context-required options passed to run(), runSync(), and runAsync() must now be wrapped in contextOptions (see the @optique/core section above).

RunOptions.help/version/completion redesigned (#130)

Object-form configurations now use { command, option } instead of { mode }. String shorthands are still accepted.

@optique/valibot and @optique/zod

placeholder option now required (#407, #727)

valibot() and zod() now require a placeholder option in their respective options objects. This value is used as a stand-in during deferred prompt resolution.

// Before
valibot(schema, { metavar: "VALUE" })
zod(schema, { metavar: "VALUE" })

// After
valibot(schema, { metavar: "VALUE", placeholder: "" })
zod(schema, { metavar: "VALUE", placeholder: "" })

@optique/temporal

Strict input shapes enforced (#314, #649)

Temporal plain parsers now reject ISO strings that are technically valid but wider than their advertised type. For example, plainDate() no longer accepts datetime strings like "2020-01-23T17:04:36", and plainDateTime() no longer accepts date-only strings like "2020-01-23". If you relied on this lenient behavior, use the appropriate parser for the actual format your users provide.

plainMonthDay() metavar changed (#306, #643)

The default metavar has changed from "--MONTH-DAY" to "MONTH-DAY". The previous metavar looked like a CLI option flag in help text.

@optique/core value parsers

DomainOptions.allowedTLDs renamed to allowedTlds (#345, #638)

Update all references to allowedTLDs to use allowedTlds.

choice() now fails on empty arrays, duplicates, and empty strings (#332, #371, #353)

choice([]) throws TypeError. Empty-string choices and duplicate case-insensitive choices also throw at construction time.

uuid() defaults to strict RFC 9562 validation (#334, #336, #670, #674)

The version digit must be 1 through 8, and the variant nibble must be in the 10xx layout. Use uuid({ strict: false }) to restore the previous lenient behavior.

integer() in number mode rejects values outside the safe integer range (#248, #525)

Values outside Number.MIN_SAFE_INTEGER to Number.MAX_SAFE_INTEGER now fail with an error instead of silently rounding. Use type: "bigint" for large integers.

Core improvements

Placeholder-based deferred prompt resolution (#407, #727)

The old approach used a branded DeferredPromptValue sentinel to mark fields not yet resolved by a prompt during phase-1 parsing. Because map() transforms could receive this sentinel as if it were a real value, the library had to strip sentinels from structured outputs using a proxy-based sanitization layer. That layer ran to roughly 1,000 lines and had persistent bugs around class instances with private fields.

The new approach is simpler: each ValueParser declares a placeholder value that prompt() uses as a phase-1 stand-in. map() receives structurally valid values from the start, and the sanitization machinery is gone entirely from both @optique/core and @optique/config.

fail<T>() parser (#120)

A new fail<T>() parser always fails without consuming input, declared to produce a value of type T. Its primary use is bindConfig(fail<T>(), ...) or bindEnv(fail<T>(), ...) when a value should come only from a config file or environment variable with no CLI flag counterpart.

validateValue() on Parser (#414, #777)

A new optional validateValue() method allows a parser to check whether an arbitrary value satisfies its underlying ValueParser's constraints. Built-in primitives implement it via a format()+parse() round-trip. bindEnv() and bindConfig() use this to enforce parser constraints on fallback values.

ValueParser.normalize() and Parser.normalizeValue() (#318, #742)

A new optional normalize() method on ValueParser canonicalizes values of the parser's type (for example, lowercasing domain names or normalizing MAC address formatting). withDefault() now normalizes default values through this method when available.

Granular hidden visibility controls (#113, #141)

The hidden option on option(), flag(), argument(), command(), passThrough(), group(), object(), and merge() now accepts boolean | "usage" | "doc" | "help" instead of just boolean. hidden: true keeps the existing behavior (hidden from usage, docs, and suggestions). "usage" and "doc" allow partial hiding, and "help" hides terms from usage and help listings while keeping them in shell completions and suggestion candidates.

leadingNames and acceptingAnyToken on Parser (#735, #741)

Each parser now reports which leading tokens it could match at the first buffer position via leadingNames, computed from structural semantics rather than the display-oriented usage tree. Shared-buffer compositions use priority ordering and acceptingAnyToken to exclude names unreachable behind a catch-all parser like argument(). This replaces the old extractLeadingOptionNames(), extractLeadingCommandNames(), and extractLeadingLiteralValues(), which produced incorrect results for tuple(), command(), and conditional().

Centralized dependency runtime (#750)

Shared-buffer constructs (object(), tuple(), concat(), merge()) now use a centralized dependency runtime for source collection, default filling, and derived-parser replay. The runtime is shared across nested constructs via ExecutionContext.dependencyRuntime and reads raw user input from a shared InputTrace instead of the old dependency-state wrapper protocol. Along with this refactor, a number of long-standing dependency resolution bugs have been fixed.

SourceContext now supports Symbol.dispose / Symbol.asyncDispose (#110)

Contexts that hold resources such as global registries can now implement Disposable or AsyncDisposable. runWith() and runWithSync() call dispose on all contexts in a finally block, including on early help, version, and completion exits.

Config source metadata for bindConfig() (#111)

bindConfig() key accessor callbacks now receive a second meta argument. In single-file mode, this metadata includes configPath and configDir so path-like options can be resolved relative to the config file location.

SourceContext.getAnnotations() receives runtime options (#110)

Contexts now receive runtime options (such as getConfigPath and load) passed by the runner, enabling config contexts to load files without a separate runner wrapper.

Equals-joined values on single-dash options (#134)

option() now accepts equals-joined values on single-dash multi-character options (for example, -seed=42 or -max_len=1000), in addition to the existing --option=value and /option:value formats. Single-character short options remain excluded from this joined form to avoid conflicts with short-option clustering.

Shell completion fixes

Shell completion saw fixes across Bash, zsh, fish, Nushell, and PowerShell:

Help and documentation output fixes

Value parser correctness

Parser correctness and runtime fixes

@optique/config improvements

@optique/git fixes

@optique/logtape fixes

@optique/man improvements

@optique/valibot and @optique/zod improvements

@optique/temporal improvements

Installation

deno add --jsr @optique/core @optique/run  # Deno
npm  add       @optique/core @optique/run  # npm
pnpm add       @optique/core @optique/run  # pnpm
yarn add       @optique/core @optique/run  # Yarn
bun  add       @optique/core @optique/run  # Bun

For the new environment variable integration:

deno add jsr:@optique/env  # Deno
npm  add     @optique/env  # npm
pnpm add     @optique/env  # pnpm
yarn add     @optique/env  # Yarn
bun  add     @optique/env  # Bun

For the new interactive prompt integration:

deno add jsr:@optique/inquirer  # Deno
npm  add     @optique/inquirer  # npm
pnpm add     @optique/inquirer  # pnpm
yarn add     @optique/inquirer  # Yarn
bun  add     @optique/inquirer  # Bun

Full documentation is available at optique.dev. The complete changelog is at optique.dev/changelog.