#Optique

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social · Reply to 洪 民憙 (Hong Minhee) :nonbinary:'s post

Commander.jsの.conflicts().implies()は、排他的な組み合わせをランタイムではちゃんと検出してくれます。

でも.opts()の型は賢くならず、戻り値は結局string | undefinedのままです。どのオプションが同時に使えないのかを、TypeScriptは知りません。

このズレをパーサーコンビネータでどう型に落とし込めるか、Yargsとの比較も含めて書きました。後半では、環境変数・設定ファイル・対話プロンプトまで同じ型保証を広げる話もしています。

https://zenn.dev/hongminhee/articles/6ba2a6247ec0c4

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social · Reply to 洪 民憙 (Hong Minhee) :nonbinary:'s post

Commander.jsの.conflicts().implies()は、排他的な組み合わせをランタイムではちゃんと検出してくれます。

でも.opts()の型は賢くならず、戻り値は結局string | undefinedのままです。どのオプションが同時に使えないのかを、TypeScriptは知りません。

このズレをパーサーコンビネータでどう型に落とし込めるか、Yargsとの比較も含めて書きました。後半では、環境変数・設定ファイル・対話プロンプトまで同じ型保証を広げる話もしています。

https://zenn.dev/hongminhee/articles/6ba2a6247ec0c4

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

I wrote about a problem that's been bugging me with .js and : .conflicts() and .implies() enforce constraints at runtime, but the type you get back is still a flat object with every field optional. The compiler has no idea which options belong together.

The post walks through what happens when you express the same constraints in the parser structure instead, and how turns that into a discriminated union where each branch carries only its own fields.

Second half covers a less obvious question: what happens when values come from env vars, config files, or prompts instead of argv, and whether the constraints should still hold across all of them.

https://hackers.pub/@hongminhee/2026/optique-10-discriminated-unions-for-cli

洪 民憙 (Hong Minhee)'s avatar
洪 民憙 (Hong Minhee)

@hongminhee@hackers.pub

You've probably written something like this in Commander.js.

import { Command, Option } from "@commander-js/extra-typings";

const program = new Command()
  .addOption(
    new Option("--token <token>", "API token").conflicts([
      "username", "password", "oauthClientId", "oauthClientSecret",
    ]),
  )
  .addOption(
    new Option("--username <username>", "Basic auth username").conflicts([
      "token", "oauthClientId", "oauthClientSecret",
    ]),
  )
  .addOption(
    new Option("--password <password>", "Basic auth password").conflicts([
      "token", "oauthClientId", "oauthClientSecret",
    ]),
  )
  .addOption(
    new Option("--oauth-client-id <id>", "OAuth client id").conflicts([
      "token", "username", "password",
    ]),
  )
  .addOption(
    new Option("--oauth-client-secret <secret>", "OAuth client secret")
      .conflicts(["token", "username", "password"]),
  );

program.parse();
const options = program.opts();

It compiles. It runs. Commander.js rejects --token abc --username alice with the conflict error you'd expect.

Look at what TypeScript thinks options is, though.

{
  token?: string | undefined;
  username?: string | undefined;
  password?: string | undefined;
  oauthClientId?: string | undefined;
  oauthClientSecret?: string | undefined;
}

Five independent optional fields. Nothing in that type says token, basic auth, and OAuth are three separate worlds. The .conflicts() chains are runtime instructions to a validator. They never touch the type. When your code reaches in and uses options, you still have to narrow by hand. Is token set? If not, can I assume username and password are both there? If you get that branching wrong, the compiler has nothing to say about it.

if (options.token != null) {
  useBasicAuth(options.username!);
}

The gap between the validator knows and the type knows is what pushed me to start building Optique. It was originally a side project for a CLI I was writing, and it's grown into something people use in earnest. A few days ago I tagged 1.0.0. The part that matters most to me is that the same parser structure now covers environment variables, config files, and prompts instead of stopping at argv.

I'll assume you've used Commander.js or Yargs before. I don't want to pretend they're bad; they're mature tools with real users. My goal is to show where they stop, and what's on the other side of that line.

Runtime checks aren't type-level knowledge

The obvious first objection to everything I just said is that Commander.js's .conflicts() isn't new. It's been there for years. Yargs has it too, along with .implies() on both sides. You can declare that --token conflicts with --username, and Yargs will even let you declare that --username implies --password so the two are required together. These aren't missing features.

On current versions, Commander.js 14 with @commander-js/extra-typings 14 handles the simple case correctly. Passing --token abc --username alice produces option '--token <token>' cannot be used with option '--username <username>', which is exactly the message the user needs.

Commander.js doesn't give you a type that reflects any of this, though. The Option.conflicts() method in extra-typings returns the Option instance unchanged. There's no generic parameter threading through the chain, accumulating which options are mutually exclusive with which others. So .opts() comes back as five optional fields, and if you write the snippet above, nothing stops you. The non-null assertion will be there in production, waiting for the input where the runtime let both through because they're actually compatible, or for the refactor that moves this code somewhere the invariant no longer holds.

Commander.js also has no way to mark a group of options as required together. If you pass --username alice and forget --password, Commander.js runs happily; the user gets a half-configured basic auth at best. .implies() exists, but it's about setting values (“if --free-drink is passed, set --drink to small“), not about requiring co-occurrence.

Yargs is stranger. It has .conflicts() and .implies(), and .implies() does enforce co-occurrence: --username without --password fails at runtime. But the interaction between the two gets confusing fast. I tried --token abc --username alice with both wired up. What Yargs told the user was:

Missing dependent arguments:
 username -> password

That's the .implies() talking. Yargs checks implies before conflicts, so the real issue (token and username are mutually exclusive) stays buried behind an unrelated complaint about a password the user never mentioned. If you add --password too, then you finally get the mutually-exclusive error. For the user on the receiving end, this is the kind of message that makes them file a bug against you.

The Yargs result type is worth seeing as well:

{
  [x: string]: unknown;
  oauthClientId: string | undefined;
  "oauth-client-id": string | undefined;
  // …the other options, each of them in both kebab- and camel-cased forms
}

The index signature [x: string]: unknown means any typo on a property access silently becomes unknown. I tried parsed.tokenn and TypeScript accepted it; the value came back undefined at runtime. Each option also shows up under both kebab-case and camelCase keys. None of this has anything to do with the exclusivity constraints I declared. It's just what happens when parser output is typed as a loose dictionary.

This is less about missing features than about where the features stop. Once you cross into the return type of .opts() or .parseSync(), the constraints are gone. The compiler sees whatever shape the signature promised, and that shape doesn't know what you declared.

Types that know which branch you picked

Here's the same CLI in Optique.

import { object, or } from "@optique/core/constructs";
import { constant, option } from "@optique/core/primitives";
import { string } from "@optique/core/valueparser";
import { run } from "@optique/run";

const parser = or(
  object({
    auth: constant("token" as const), 
    token: option("--token", string({ metavar: "TOKEN" })),
  }),
  object({
    auth: constant("basic" as const), 
    username: option("--username", string({ metavar: "USER" })),
    password: option("--password", string({ metavar: "PASS" })),
  }),
  object({
    auth: constant("oauth" as const), 
    clientId: option("--oauth-client-id", string({ metavar: "ID" })),
    clientSecret: option("--oauth-client-secret", string({ metavar: "SECRET" })),
  }),
);

const parsed = run(parser);

The shape of the code is different. Instead of declaring each option in isolation and then chaining constraints between them, you describe three complete parsers, one per auth method, and pass them to or(). Each branch is an object() that lists the options belonging to that branch. The constant() calls are discriminators; they don't consume input, they just tag the result.

The type parsed gets is:

  | { readonly auth: "token"; readonly token: string }
  | { readonly auth: "basic"; readonly username: string; readonly password: string }
  | { readonly auth: "oauth"; readonly clientId: string; readonly clientSecret: string }

That's a discriminated union. When you consume the parsed value, TypeScript knows which fields are available based on auth:

switch (parsed.auth) {
  case "token":
    await callApiWithToken(parsed.token);
    break;
  case "basic":
    await callApiWithBasic(parsed.username, parsed.password);
    break;
  case "oauth":
    await callApiWithOauth(parsed.clientId, parsed.clientSecret);
    break;
}

Inside the "token" case, parsed.username is a type error. Inside "basic", parsed.token is a type error. Every field inside its branch is plain string, not string | undefined, so no non-null assertions are asked for. If you add a fourth auth method next year and forget to update the switch, the compiler complains.

The runtime errors follow the parser shape too. A token-plus-username mix fails as a conflict: "--token" "abc" and "--username" "alice" cannot be used together. A basic-auth branch without its password fails as missing input: Missing option --password. No check-ordering coincidences.

I want to be clear that this idea isn't mine. Parser combinators have been a standard technique in functional programming for decades, and Haskell's optparse-applicative has been applying them to CLI parsing since 2012. TypeScript's conditional types and discriminated union inference happen to be strong enough that this style of API doesn't ask you to write types by hand. You compose parsers, and the types work out.

I started on Optique while trying to express this kind of structure in Fedify's CLI. The closest tool for the shape of problem I had was Cliffy, but it didn't fit[1]. Commander.js and Yargs couldn't express it the way I wanted either. So here we are.

CLI arguments are one place values come from; there are others

So far this example is unrealistically argv-only. Real CLIs pull some values from environment variables (GITHUB_TOKEN, DATABASE_URL, AWS_REGION), some from config files because nobody wants to retype seventeen flags on every invocation, and some from interactive prompts because secrets shouldn't sit in shell history.

On Commander.js or Yargs, each of those sources is usually a separate mechanism. Commander.js has .env() on options, which is fine for that one dimension. Config files get a separate library, or a hand-rolled loader at the top of main(). Interactive prompts are Inquirer.js, wired in somewhere. Each has its own validation path, and reconciling precedence between them is your problem.

In 1.0, Optique treats these four sources as one problem.

One parser, four sources

Take the auth example again, but think about where each value really comes from in practice. API tokens come from environment variables; nobody types GITHUB_TOKEN on the command line. Passwords should be prompted, not left in shell history. OAuth client credentials get saved to a config file because they're project-scoped and you want them versioned (the client secret less so, but let's keep the example simple).

Here's how you'd wire that up in Optique 1.0.

import { object, or } from "@optique/core/constructs";
import { constant, option } from "@optique/core/primitives";
import { string } from "@optique/core/valueparser";
import { bindEnv, createEnvContext } from "@optique/env";
import { bindConfig, createConfigContext } from "@optique/config";
import { prompt } from "@optique/inquirer";
import { runAsync } from "@optique/run";
import { z } from "zod";

const envCtx = createEnvContext({ prefix: "MYAPP_" });
const cfgCtx = createConfigContext({
  schema: z.object({
    oauth: z.object({
      clientId: z.string().optional(),
      clientSecret: z.string().optional(),
    }).optional(),
  }),
});

const parser = or(
  object({
    auth: constant("token" as const),
    token: bindEnv( 
      option("--token", string()),
      { context: envCtx, key: "TOKEN", parser: string() },
    ),
  }),
  object({
    auth: constant("basic" as const),
    username: option("--username", string()),
    password: prompt( 
      option("--password", string()),
      { type: "password", message: "Password:", mask: true },
    ),
  }),
  object({
    auth: constant("oauth" as const),
    clientId: bindConfig( 
      option("--oauth-client-id", string()),
      { context: cfgCtx, key: (c) => c?.oauth?.clientId },
    ),
    clientSecret: bindConfig( 
      option("--oauth-client-secret", string()),
      { context: cfgCtx, key: (c) => c?.oauth?.clientSecret },
    ),
  }),
);

const parsed = await runAsync(parser, { contexts: [envCtx, cfgCtx] });

The parser structure hasn't changed. It's still three branches, each an object() of required fields. What changed is that each field is now wrapped with one or more of bindEnv(), bindConfig(), and prompt(). These wrappers don't alter what a field means; they describe where to look for its value if argv didn't supply one.

Resolution order follows wrapper nesting from the inside out. Whatever the user put on the command line wins, then the environment variable, then the config file, then the prompt. If the user gave --token explicitly, MYAPP_TOKEN is ignored for this run. If they didn't but it's set in the environment, the prompt never fires. You can stack all four on a single option if you want; a common pattern is prompt(bindEnv(bindConfig(option(…), …), …), …), which gives you CLI then env then config then prompt on one value.

The inferred type is unchanged from the pure-argv version above. The branches are still discriminated by auth. Every field in the selected branch is still string, not string | undefined. The type system has no idea that some of these values took a detour through the filesystem or a TTY before they got to you.

A small related feature is fail<T>(). Sometimes a value shouldn't be exposed as a CLI flag at all; maybe it's a secret that should only come from config or env. bindConfig(fail<string>(), { … }) expresses that. The parser has no CLI surface for the field, but it still participates in the type, and it still feeds the config value into the result.

The “express constraints through structure” idea earns its keep here. Teams who've wanted this combination on Commander.js or Yargs have historically had to stitch it together: .env() here, a config loader there, an Inquirer.js block inside the action handler, then a pile of if-statements reconciling what to believe when two sources disagree. The reconciliation code is where the bugs live. bindEnv(bindConfig(…)) is that reconciliation, but written once and tested once instead of re-implemented per CLI.

Constraints that don't leak

There's a subtler problem that I didn't fully appreciate until late in the 0.x cycle. Consider this:

option("--port", integer({ min: 1024, max: 65535 }))

At the CLI, the parser rejects --port 80. Good. Now wrap it in bindEnv():

bindEnv(
  option("--port", integer({ min: 1024, max: 65535 })),
  { context: envCtx, key: "PORT", parser: integer() },
)

In 0.x, if the user left --port off and set PORT=80 in the environment, the value 80 would flow through untouched. The env-level parser here is integer() without bounds, so it accepted. The CLI-level parser's constraints never ran on values that didn't arrive via argv. Config files had the same hole: a constraint written into the CLI option could be silently bypassed by a different source.

This isn't the sort of bug that shows up in the tests you'd normally write. It shows up when somebody sets an environment variable in production and a value that should've been rejected sails through to the rest of the application.

1.0 adds a Parser.validateValue() method that fallback paths use. Environment values, config values, and defaults are now re-validated against the CLI parser's constraints on their way in. The rule is consistent: if a value wouldn't be accepted from argv, it's not accepted from anywhere else either.

I'd always described Optique as a “parse, don't validate” library. The phrase is shorthand for an approach where you don't run a separate validation pass after parsing; the parser itself rejects invalid input up front. 0.x mostly delivered on that for argv. 1.0 extends it to every source a value can enter from.

When Optique isn't the right choice

If your CLI has four flags and no subcommands, use Commander.js. You'll be done faster, your bundle will be smaller, and whoever reviews the PR won't have to learn a new mental model. Optique pays off once you have nontrivial structure: mutually exclusive groups, co-required options, values that arrive from multiple sources, subcommands with per-subcommand option sets. Below that complexity bar, its abstractions are overhead you're paying for no return.

If you have a large Commander.js codebase that works, don't port it. The path from imperative configuration to parser combinators isn't a three-hour rewrite, and the bug you introduce during the rewrite is rarely worth the cleaner types afterward. I'd reach for Optique on a new CLI, or on a new subcommand being added to an existing app, not on a retroactive migration.

If you need a specific Commander.js or Yargs plugin that does something exotic, Optique's ecosystem is smaller. I expect that to change. I shouldn't pretend it isn't smaller today.

There's a more uncomfortable question too. If your CLI is complex enough to benefit from Optique, maybe the CLI itself has too many knobs. Optique helps you build a TV remote where every button is correctly wired and no two conflict, but it doesn't ask whether the remote should have that many buttons in the first place. If you find yourself reaching for deeply nested or() trees, consider simplifying the interface before modeling it more precisely.

I think about this sometimes. Some interfaces genuinely need cockpit-level density: database admin tools, deployment pipelines, build systems. Optique is at its best when the complexity is real. When it's accumulated through feature creep, no parser library will save you.

1.0 means I can stop adding footnotes

Through most of 0.x, recommending Optique to anyone required footnotes. The env package isn't stable yet. The prompt API might change. runWithConfig is on the way out, use X for now. This constraint doesn't carry across env boundaries, so double-check. The library worked, but the surface I was asking people to commit to was moving.

That's what 1.0 changes for me. I can send someone the docs link without a page of caveats first.

Documentation is at optique.dev. The 1.0 announcement and changelog are on GitHub. Issues and discussions are the place to tell me where the sharp edges still are.


  1. Two reasons. Cliffy is Deno-only, which rules it out for a CLI that needs to ship on Node.js and Bun. And even in Deno, Cliffy's API is declarative in roughly the same way as Yargs: options and constraints are declared against a runtime validator, not composed into types. The limits we've just been walking through on Commander.js and Yargs show up in Cliffy too, in a different dialect. ↩︎

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

1.0.0 is out! If you build tools with , it might be worth a look.

I started it because I wanted a TypeScript CLI parser that felt more like optparse-applicative than the usual builder-style APIs. You build up small typed parsers, compose them, and TypeScript infers the result. It handles subcommands, option dependencies, shell completion, and man pages, and it runs on , .js, and .

For 1.0 I added @optique/env, so env vars can fill in missing flags, and @optique/inquirer, so missing values can fall back to Inquirer.js prompts. I also cleaned up a lot of awkward API edges and fixed a long backlog of completion bugs across five shells.

Packages are on JSR and npm.

https://github.com/dahlia/optique/discussions/796

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

1.0.0 is out! If you build tools with , it might be worth a look.

I started it because I wanted a TypeScript CLI parser that felt more like optparse-applicative than the usual builder-style APIs. You build up small typed parsers, compose them, and TypeScript infers the result. It handles subcommands, option dependencies, shell completion, and man pages, and it runs on , .js, and .

For 1.0 I added @optique/env, so env vars can fill in missing flags, and @optique/inquirer, so missing values can fall back to Inquirer.js prompts. I also cleaned up a lot of awkward API edges and fixed a long backlog of completion bugs across five shells.

Packages are on JSR and npm.

https://github.com/dahlia/optique/discussions/796

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

1.0.0 is out! If you build tools with , it might be worth a look.

I started it because I wanted a TypeScript CLI parser that felt more like optparse-applicative than the usual builder-style APIs. You build up small typed parsers, compose them, and TypeScript infers the result. It handles subcommands, option dependencies, shell completion, and man pages, and it runs on , .js, and .

For 1.0 I added @optique/env, so env vars can fill in missing flags, and @optique/inquirer, so missing values can fall back to Inquirer.js prompts. I also cleaned up a lot of awkward API edges and fixed a long backlog of completion bugs across five shells.

Packages are on JSR and npm.

https://github.com/dahlia/optique/discussions/796

Alejandro Baez's avatar
Alejandro Baez

@zeab@fosstodon.org

Interesting to see combinator conditions on for CLI.

This is something that's hard to do in any CLI library. Easier with typed languages, but still can be complex. Very cool to see with and their approach leveraging the type system. 😎

optique.dev/why

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

Optique just crossed 600 GitHub stars!

For those unfamiliar: is a parsing library for that takes a parser combinator approach, inspired by Haskell's optparse-applicative. The core idea is “parse, don't validate”—you express constraints like mutually exclusive options or dependent flags through types, and TypeScript infers the rest automatically. No runtime validation boilerplate needed.

It started as something I built out of frustration while working on Fedify, an ActivityPub framework, when no existing CLI library could express the constraints I needed in a type-safe way. Apparently I wasn't the only one who felt that way.

Thank you all for the support.

https://github.com/dahlia/optique

Screenshot of the GitHub repository page for dahlia/optique. The repository header shows a fork count of 7 and a star count of 601. The navigation tabs show Code, Issues (312), Pull requests, Discussions, Actions, and Security. The current branch is main, with the latest commit hash 9b28b85 made 18 minutes ago. The About section on the right reads “type-safe combinatorial CLI parser for TypeScript” with a link to optique.dev.
ALT text detailsScreenshot of the GitHub repository page for dahlia/optique. The repository header shows a fork count of 7 and a star count of 601. The navigation tabs show Code, Issues (312), Pull requests, Discussions, Actions, and Security. The current branch is main, with the latest commit hash 9b28b85 made 18 minutes ago. The About section on the right reads “type-safe combinatorial CLI parser for TypeScript” with a link to optique.dev.
洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

Optique just crossed 600 GitHub stars!

For those unfamiliar: is a parsing library for that takes a parser combinator approach, inspired by Haskell's optparse-applicative. The core idea is “parse, don't validate”—you express constraints like mutually exclusive options or dependent flags through types, and TypeScript infers the rest automatically. No runtime validation boilerplate needed.

It started as something I built out of frustration while working on Fedify, an ActivityPub framework, when no existing CLI library could express the constraints I needed in a type-safe way. Apparently I wasn't the only one who felt that way.

Thank you all for the support.

https://github.com/dahlia/optique

Screenshot of the GitHub repository page for dahlia/optique. The repository header shows a fork count of 7 and a star count of 601. The navigation tabs show Code, Issues (312), Pull requests, Discussions, Actions, and Security. The current branch is main, with the latest commit hash 9b28b85 made 18 minutes ago. The About section on the right reads “type-safe combinatorial CLI parser for TypeScript” with a link to optique.dev.
ALT text detailsScreenshot of the GitHub repository page for dahlia/optique. The repository header shows a fork count of 7 and a star count of 601. The navigation tabs show Code, Issues (312), Pull requests, Discussions, Actions, and Security. The current branch is main, with the latest commit hash 9b28b85 made 18 minutes ago. The About section on the right reads “type-safe combinatorial CLI parser for TypeScript” with a link to optique.dev.
洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

1.0.0 is shaping up, and three API changes are worth knowing about in advance.

  • Runner consolidation: run() from @optique/run now accepts source contexts directly, which makes runWith() and runWithConfig() redundant for most use cases. runWithConfig() is removed outright—no deprecation, since we have a major version to absorb the break. For the typical CLI, run() is now the single entry point.

  • Meta command config redesign: help, version, and completion in RunOptions no longer use mode: "command" | "option" | "both". Each now takes independent command and option sub-configs, which makes it possible to give --help a -h alias, hide a meta command from usage lines while keeping it in the help listing, or group the command and option forms differently. String shorthands (help: "both", version: "1.2.3", etc.) still work exactly as before.

  • Config-file-relative paths: bindConfig()'s key callback now receives config file metadata as a second argument—configDir and configPath—so you can resolve paths relative to the config file's location rather than the working directory. This matches how tools like the TypeScript compiler handle outDir and similar path options.

More details on the 1.0.0 milestone.

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

1.0.0 is shaping up, and three API changes are worth knowing about in advance.

  • Runner consolidation: run() from @optique/run now accepts source contexts directly, which makes runWith() and runWithConfig() redundant for most use cases. runWithConfig() is removed outright—no deprecation, since we have a major version to absorb the break. For the typical CLI, run() is now the single entry point.

  • Meta command config redesign: help, version, and completion in RunOptions no longer use mode: "command" | "option" | "both". Each now takes independent command and option sub-configs, which makes it possible to give --help a -h alias, hide a meta command from usage lines while keeping it in the help listing, or group the command and option forms differently. String shorthands (help: "both", version: "1.2.3", etc.) still work exactly as before.

  • Config-file-relative paths: bindConfig()'s key callback now receives config file metadata as a second argument—configDir and configPath—so you can resolve paths relative to the config file's location rather than the working directory. This matches how tools like the TypeScript compiler handle outDir and similar path options.

More details on the 1.0.0 milestone.

Alejandro Baez's avatar
Alejandro Baez

@zeab@fosstodon.org

Interesting to see combinator conditions on for CLI.

This is something that's hard to do in any CLI library. Easier with typed languages, but still can be complex. Very cool to see with and their approach leveraging the type system. 😎

optique.dev/why

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

0.9.0 is here!

This release brings /await support to parsers. Now you can validate input against external resources—databases, APIs, Git repositories—directly at parse time, with full type safety.

The new @optique/git package showcases this: validate branch names, tags, and commit SHAs against an actual Git repo, complete with shell completion suggestions.

Other highlights:

  • Hidden option support for deprecated/internal flags
  • Numeric choices in choice()
  • Security fix for shell completion scripts

Fully backward compatible—your existing parsers work unchanged.

https://github.com/dahlia/optique/discussions/75

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

0.9.0 is here!

This release brings /await support to parsers. Now you can validate input against external resources—databases, APIs, Git repositories—directly at parse time, with full type safety.

The new @optique/git package showcases this: validate branch names, tags, and commit SHAs against an actual Git repo, complete with shell completion suggestions.

Other highlights:

  • Hidden option support for deprecated/internal flags
  • Numeric choices in choice()
  • Security fix for shell completion scripts

Fully backward compatible—your existing parsers work unchanged.

https://github.com/dahlia/optique/discussions/75

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

0.9.0 is here!

This release brings /await support to parsers. Now you can validate input against external resources—databases, APIs, Git repositories—directly at parse time, with full type safety.

The new @optique/git package showcases this: validate branch names, tags, and commit SHAs against an actual Git repo, complete with shell completion suggestions.

Other highlights:

  • Hidden option support for deprecated/internal flags
  • Numeric choices in choice()
  • Security fix for shell completion scripts

Fully backward compatible—your existing parsers work unchanged.

https://github.com/dahlia/optique/discussions/75

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

Here's a API design challenge I'm working on: adding async support to (CLI argument parser) without breaking the existing sync API.

The tricky part is combinators—when you compose parsers with object() or or(), the combined parser should automatically become async if any child parser is async, but stay sync if all children are sync. This “mode propagation” needs to work at both type level and runtime.

I've prototyped two approaches and documented findings. If you've tackled similar dual-mode API designs, I'd love to hear how you approached it.

https://github.com/dahlia/optique/issues/52

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

Here's a API design challenge I'm working on: adding async support to (CLI argument parser) without breaking the existing sync API.

The tricky part is combinators—when you compose parsers with object() or or(), the combined parser should automatically become async if any child parser is async, but stay sync if all children are sync. This “mode propagation” needs to work at both type level and runtime.

I've prototyped two approaches and documented findings. If you've tackled similar dual-mode API designs, I'd love to hear how you approached it.

https://github.com/dahlia/optique/issues/52

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

Here's a API design challenge I'm working on: adding async support to (CLI argument parser) without breaking the existing sync API.

The tricky part is combinators—when you compose parsers with object() or or(), the combined parser should automatically become async if any child parser is async, but stay sync if all children are sync. This “mode propagation” needs to work at both type level and runtime.

I've prototyped two approaches and documented findings. If you've tackled similar dual-mode API designs, I'd love to hear how you approached it.

https://github.com/dahlia/optique/issues/52

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

CLIパーサーの新しい記事を書きました。--reporterの値によって--output-fileが必須になったり禁止になったり…そういう関係、型で表現できたら楽じゃないですか?

https://zenn.dev/hongminhee/articles/201ca6d2e57764

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

CLIパーサーの新しい記事を書きました。--reporterの値によって--output-fileが必須になったり禁止になったり…そういう関係、型で表現できたら楽じゃないですか?

https://zenn.dev/hongminhee/articles/201ca6d2e57764

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

CLIパーサーの新しい記事を書きました。--reporterの値によって--output-fileが必須になったり禁止になったり…そういう関係、型で表現できたら楽じゃないですか?

https://zenn.dev/hongminhee/articles/201ca6d2e57764

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

CLIパーサーの新しい記事を書きました。--reporterの値によって--output-fileが必須になったり禁止になったり…そういう関係、型で表現できたら楽じゃないですか?

https://zenn.dev/hongminhee/articles/201ca6d2e57764

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

CLIパーサーの新しい記事を書きました。--reporterの値によって--output-fileが必須になったり禁止になったり…そういう関係、型で表現できたら楽じゃないですか?

https://zenn.dev/hongminhee/articles/201ca6d2e57764

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

CLIパーサーの新しい記事を書きました。--reporterの値によって--output-fileが必須になったり禁止になったり…そういう関係、型で表現できたら楽じゃないですか?

https://zenn.dev/hongminhee/articles/201ca6d2e57764

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

Interesting design question for (a type-safe parser for ): how should it handle unrecognized options in wrapper/proxy tools? Proposed three modes but wondering if the complexity is worth it. Thoughts?

https://github.com/dahlia/optique/issues/35

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

Interesting design question for (a type-safe parser for ): how should it handle unrecognized options in wrapper/proxy tools? Proposed three modes but wondering if the complexity is worth it. Thoughts?

https://github.com/dahlia/optique/issues/35

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

Interesting design question for (a type-safe parser for ): how should it handle unrecognized options in wrapper/proxy tools? Proposed three modes but wondering if the complexity is worth it. Thoughts?

https://github.com/dahlia/optique/issues/35

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

Interesting design question for (a type-safe parser for ): how should it handle unrecognized options in wrapper/proxy tools? Proposed three modes but wondering if the complexity is worth it. Thoughts?

https://github.com/dahlia/optique/issues/35

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

0.3.0 is out with dependent options and flexible parser composition, shaped by feedback from @z9mb1's work migrating @fedify CLI from Cliffy to Optique.

https://hackers.pub/@hongminhee/2025/optique-030

洪 民憙 (Hong Minhee)'s avatar
洪 民憙 (Hong Minhee)

@hongminhee@hackers.pub


We're releasing Optique 0.3.0 with several improvements that make building complex CLI applications more straightforward. This release focuses on expanding parser flexibility and improving the help system based on feedback from the community, particularly from the Fedify project's migration from Cliffy to Optique. Special thanks to @z9mb1 for her invaluable insights during this process.

What's new

  • Required Boolean flags with the new flag() parser for dependent options patterns
  • Flexible type defaults in withDefault() supporting union types for conditional CLI structures
  • Extended or() capacity now supporting up to 10 parsers (previously 5)
  • Enhanced merge() combinator that works with any object-producing parser, not just object()
  • Context-aware help using the new longestMatch() combinator
  • Version display support in both @optique/core and @optique/run
  • Structured output functions for consistent terminal formatting

Required Boolean flags with flag()

The new flag() parser creates Boolean flags that must be explicitly provided. While option() defaults to false when absent, flag() fails parsing entirely if not present. This subtle difference enables cleaner patterns for dependent options.

Consider a scenario where certain options only make sense when a mode is explicitly enabled:

import { flag, object, option, withDefault } from "@optique/core/parser";
import { integer } from "@optique/core/valueparser";

// Without the --advanced flag, these options aren't available
const parser = withDefault(
  object({
    advanced: flag("--advanced"),
    maxThreads: option("--threads", integer()),
    cacheSize: option("--cache-size", integer())
  }),
  { advanced: false as const }
);

// Usage:
// myapp                    → { advanced: false }
// myapp --advanced         → Error: --threads and --cache-size required
// myapp --advanced --threads 4 --cache-size 100 → Success

This pattern is particularly useful for confirmation flags (--yes-i-am-sure) or mode switches that fundamentally change how your CLI behaves.

Union types in withDefault()

Previously, withDefault() required the default value to match the parser's type exactly. Now it supports different types, creating union types that enable conditional CLI structures:

const conditionalParser = withDefault(
  object({
    server: flag("-s", "--server"),
    port: option("-p", "--port", integer()),
    host: option("-h", "--host", string())
  }),
  { server: false as const }
);

// Result type is now a union:
// | { server: false }
// | { server: true, port: number, host: string }

This change makes it much easier to build CLIs where different flags enable different sets of options, without resorting to complex or() chains.

More flexible merge() combinator

The merge() combinator now accepts any parser that produces object-like values. Previously limited to object() parsers, it now works with withDefault(), map(), and other transformative parsers:

const transformedConfig = map(
  object({
    host: option("--host", string()),
    port: option("--port", integer())
  }),
  ({ host, port }) => ({ endpoint: `${host}:${port}` })
);

const conditionalFeatures = withDefault(
  object({
    experimental: flag("--experimental"),
    debugLevel: option("--debug-level", integer())
  }),
  { experimental: false as const }
);

// Can now merge different parser types
const appConfig = merge(
  transformedConfig,        // map() result
  conditionalFeatures,      // withDefault() parser
  object({                  // traditional object()
    verbose: option("-v", "--verbose")
  })
);

This improvement came from recognizing that many parsers ultimately produce objects, and artificially restricting merge() to only object() parsers was limiting composition patterns.

Context-aware help with longestMatch()

The new longestMatch() combinator selects the parser that consumes the most input tokens. This enables sophisticated help systems where command --help shows help for that specific command rather than global help:

const normalParser = object({
  help: constant(false),
  command: or(
    command("list", listOptions),
    command("add", addOptions)
  )
});

const contextualHelp = object({
  help: constant(true),
  commands: multiple(argument(string())),
  helpFlag: flag("--help")
});

const cli = longestMatch(normalParser, contextualHelp);

// myapp --help           → Shows global help
// myapp list --help      → Shows help for 'list' command
// myapp add --help       → Shows help for 'add' command

The run() functions in both @optique/core/facade and @optique/run now use this pattern automatically, so your CLI gets context-aware help without any additional configuration.

Version display support

Both @optique/core/facade and @optique/run now support version display through --version flags and version commands. See the runners documentation for details:

// @optique/run - simple API
run(parser, {
  version: "1.0.0",  // Adds --version flag
  help: "both"
});

// @optique/core/facade - detailed control
run(parser, "myapp", args, {
  version: {
    mode: "both",     // --version flag AND version command
    value: "1.0.0",
    onShow: process.exit
  }
});

The API follows the same pattern as help configuration, keeping things consistent and predictable.

Structured output functions

The new output functions in @optique/run provide consistent terminal formatting with automatic capability detection. Learn more in the messages documentation:

import { print, printError, createPrinter } from "@optique/run";
import { message } from "@optique/core/message";

// Standard output with automatic formatting
print(message`Processing ${filename}...`);

// Error output to stderr with optional exit
printError(message`File ${filename} not found`, { exitCode: 1 });

// Custom printer for specific needs
const debugPrint = createPrinter({
  stream: "stderr",
  colors: true,
  maxWidth: 80
});

debugPrint(message`Debug: ${details}`);

These functions automatically detect terminal capabilities and apply appropriate formatting, making your CLI output consistent across different environments.

Breaking changes

While we've tried to maintain backward compatibility, there are a few changes to be aware of:

  • The help option in @optique/run no longer accepts "none". Simply omit the option to disable help.
  • Custom parsers implementing getDocFragments() need to update their signature to use DocState<TState> instead of direct state values.
  • The object() parser now uses greedy parsing, attempting to consume all matching fields in one pass. This shouldn't affect most use cases but may change parsing order in complex scenarios.

Upgrading to 0.3.0

To upgrade to Optique 0.3.0, update both packages:

# Deno (JSR)
deno add @optique/core@^0.3.0 @optique/run@^0.3.0

# npm
npm update @optique/core @optique/run

# pnpm
pnpm update @optique/core @optique/run

# Yarn
yarn upgrade @optique/core @optique/run

# Bun
bun update @optique/core @optique/run

If you're only using the core package:

# Deno (JSR)
deno add @optique/core@^0.3.0

# npm
npm update @optique/core

Looking ahead

These improvements came from real-world usage and community feedback. We're particularly interested in hearing how the new dependent options patterns work for your use cases, and whether the context-aware help system meets your needs.

As always, you can find complete documentation at optique.dev and file issues or suggestions on GitHub.

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

0.3.0 is out with dependent options and flexible parser composition, shaped by feedback from @z9mb1's work migrating @fedify CLI from Cliffy to Optique.

https://hackers.pub/@hongminhee/2025/optique-030

洪 民憙 (Hong Minhee)'s avatar
洪 民憙 (Hong Minhee)

@hongminhee@hackers.pub


We're releasing Optique 0.3.0 with several improvements that make building complex CLI applications more straightforward. This release focuses on expanding parser flexibility and improving the help system based on feedback from the community, particularly from the Fedify project's migration from Cliffy to Optique. Special thanks to @z9mb1 for her invaluable insights during this process.

What's new

  • Required Boolean flags with the new flag() parser for dependent options patterns
  • Flexible type defaults in withDefault() supporting union types for conditional CLI structures
  • Extended or() capacity now supporting up to 10 parsers (previously 5)
  • Enhanced merge() combinator that works with any object-producing parser, not just object()
  • Context-aware help using the new longestMatch() combinator
  • Version display support in both @optique/core and @optique/run
  • Structured output functions for consistent terminal formatting

Required Boolean flags with flag()

The new flag() parser creates Boolean flags that must be explicitly provided. While option() defaults to false when absent, flag() fails parsing entirely if not present. This subtle difference enables cleaner patterns for dependent options.

Consider a scenario where certain options only make sense when a mode is explicitly enabled:

import { flag, object, option, withDefault } from "@optique/core/parser";
import { integer } from "@optique/core/valueparser";

// Without the --advanced flag, these options aren't available
const parser = withDefault(
  object({
    advanced: flag("--advanced"),
    maxThreads: option("--threads", integer()),
    cacheSize: option("--cache-size", integer())
  }),
  { advanced: false as const }
);

// Usage:
// myapp                    → { advanced: false }
// myapp --advanced         → Error: --threads and --cache-size required
// myapp --advanced --threads 4 --cache-size 100 → Success

This pattern is particularly useful for confirmation flags (--yes-i-am-sure) or mode switches that fundamentally change how your CLI behaves.

Union types in withDefault()

Previously, withDefault() required the default value to match the parser's type exactly. Now it supports different types, creating union types that enable conditional CLI structures:

const conditionalParser = withDefault(
  object({
    server: flag("-s", "--server"),
    port: option("-p", "--port", integer()),
    host: option("-h", "--host", string())
  }),
  { server: false as const }
);

// Result type is now a union:
// | { server: false }
// | { server: true, port: number, host: string }

This change makes it much easier to build CLIs where different flags enable different sets of options, without resorting to complex or() chains.

More flexible merge() combinator

The merge() combinator now accepts any parser that produces object-like values. Previously limited to object() parsers, it now works with withDefault(), map(), and other transformative parsers:

const transformedConfig = map(
  object({
    host: option("--host", string()),
    port: option("--port", integer())
  }),
  ({ host, port }) => ({ endpoint: `${host}:${port}` })
);

const conditionalFeatures = withDefault(
  object({
    experimental: flag("--experimental"),
    debugLevel: option("--debug-level", integer())
  }),
  { experimental: false as const }
);

// Can now merge different parser types
const appConfig = merge(
  transformedConfig,        // map() result
  conditionalFeatures,      // withDefault() parser
  object({                  // traditional object()
    verbose: option("-v", "--verbose")
  })
);

This improvement came from recognizing that many parsers ultimately produce objects, and artificially restricting merge() to only object() parsers was limiting composition patterns.

Context-aware help with longestMatch()

The new longestMatch() combinator selects the parser that consumes the most input tokens. This enables sophisticated help systems where command --help shows help for that specific command rather than global help:

const normalParser = object({
  help: constant(false),
  command: or(
    command("list", listOptions),
    command("add", addOptions)
  )
});

const contextualHelp = object({
  help: constant(true),
  commands: multiple(argument(string())),
  helpFlag: flag("--help")
});

const cli = longestMatch(normalParser, contextualHelp);

// myapp --help           → Shows global help
// myapp list --help      → Shows help for 'list' command
// myapp add --help       → Shows help for 'add' command

The run() functions in both @optique/core/facade and @optique/run now use this pattern automatically, so your CLI gets context-aware help without any additional configuration.

Version display support

Both @optique/core/facade and @optique/run now support version display through --version flags and version commands. See the runners documentation for details:

// @optique/run - simple API
run(parser, {
  version: "1.0.0",  // Adds --version flag
  help: "both"
});

// @optique/core/facade - detailed control
run(parser, "myapp", args, {
  version: {
    mode: "both",     // --version flag AND version command
    value: "1.0.0",
    onShow: process.exit
  }
});

The API follows the same pattern as help configuration, keeping things consistent and predictable.

Structured output functions

The new output functions in @optique/run provide consistent terminal formatting with automatic capability detection. Learn more in the messages documentation:

import { print, printError, createPrinter } from "@optique/run";
import { message } from "@optique/core/message";

// Standard output with automatic formatting
print(message`Processing ${filename}...`);

// Error output to stderr with optional exit
printError(message`File ${filename} not found`, { exitCode: 1 });

// Custom printer for specific needs
const debugPrint = createPrinter({
  stream: "stderr",
  colors: true,
  maxWidth: 80
});

debugPrint(message`Debug: ${details}`);

These functions automatically detect terminal capabilities and apply appropriate formatting, making your CLI output consistent across different environments.

Breaking changes

While we've tried to maintain backward compatibility, there are a few changes to be aware of:

  • The help option in @optique/run no longer accepts "none". Simply omit the option to disable help.
  • Custom parsers implementing getDocFragments() need to update their signature to use DocState<TState> instead of direct state values.
  • The object() parser now uses greedy parsing, attempting to consume all matching fields in one pass. This shouldn't affect most use cases but may change parsing order in complex scenarios.

Upgrading to 0.3.0

To upgrade to Optique 0.3.0, update both packages:

# Deno (JSR)
deno add @optique/core@^0.3.0 @optique/run@^0.3.0

# npm
npm update @optique/core @optique/run

# pnpm
pnpm update @optique/core @optique/run

# Yarn
yarn upgrade @optique/core @optique/run

# Bun
bun update @optique/core @optique/run

If you're only using the core package:

# Deno (JSR)
deno add @optique/core@^0.3.0

# npm
npm update @optique/core

Looking ahead

These improvements came from real-world usage and community feedback. We're particularly interested in hearing how the new dependent options patterns work for your use cases, and whether the context-aware help system meets your needs.

As always, you can find complete documentation at optique.dev and file issues or suggestions on GitHub.

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

0.3.0 is out with dependent options and flexible parser composition, shaped by feedback from @z9mb1's work migrating @fedify CLI from Cliffy to Optique.

https://hackers.pub/@hongminhee/2025/optique-030

洪 民憙 (Hong Minhee)'s avatar
洪 民憙 (Hong Minhee)

@hongminhee@hackers.pub


We're releasing Optique 0.3.0 with several improvements that make building complex CLI applications more straightforward. This release focuses on expanding parser flexibility and improving the help system based on feedback from the community, particularly from the Fedify project's migration from Cliffy to Optique. Special thanks to @z9mb1 for her invaluable insights during this process.

What's new

  • Required Boolean flags with the new flag() parser for dependent options patterns
  • Flexible type defaults in withDefault() supporting union types for conditional CLI structures
  • Extended or() capacity now supporting up to 10 parsers (previously 5)
  • Enhanced merge() combinator that works with any object-producing parser, not just object()
  • Context-aware help using the new longestMatch() combinator
  • Version display support in both @optique/core and @optique/run
  • Structured output functions for consistent terminal formatting

Required Boolean flags with flag()

The new flag() parser creates Boolean flags that must be explicitly provided. While option() defaults to false when absent, flag() fails parsing entirely if not present. This subtle difference enables cleaner patterns for dependent options.

Consider a scenario where certain options only make sense when a mode is explicitly enabled:

import { flag, object, option, withDefault } from "@optique/core/parser";
import { integer } from "@optique/core/valueparser";

// Without the --advanced flag, these options aren't available
const parser = withDefault(
  object({
    advanced: flag("--advanced"),
    maxThreads: option("--threads", integer()),
    cacheSize: option("--cache-size", integer())
  }),
  { advanced: false as const }
);

// Usage:
// myapp                    → { advanced: false }
// myapp --advanced         → Error: --threads and --cache-size required
// myapp --advanced --threads 4 --cache-size 100 → Success

This pattern is particularly useful for confirmation flags (--yes-i-am-sure) or mode switches that fundamentally change how your CLI behaves.

Union types in withDefault()

Previously, withDefault() required the default value to match the parser's type exactly. Now it supports different types, creating union types that enable conditional CLI structures:

const conditionalParser = withDefault(
  object({
    server: flag("-s", "--server"),
    port: option("-p", "--port", integer()),
    host: option("-h", "--host", string())
  }),
  { server: false as const }
);

// Result type is now a union:
// | { server: false }
// | { server: true, port: number, host: string }

This change makes it much easier to build CLIs where different flags enable different sets of options, without resorting to complex or() chains.

More flexible merge() combinator

The merge() combinator now accepts any parser that produces object-like values. Previously limited to object() parsers, it now works with withDefault(), map(), and other transformative parsers:

const transformedConfig = map(
  object({
    host: option("--host", string()),
    port: option("--port", integer())
  }),
  ({ host, port }) => ({ endpoint: `${host}:${port}` })
);

const conditionalFeatures = withDefault(
  object({
    experimental: flag("--experimental"),
    debugLevel: option("--debug-level", integer())
  }),
  { experimental: false as const }
);

// Can now merge different parser types
const appConfig = merge(
  transformedConfig,        // map() result
  conditionalFeatures,      // withDefault() parser
  object({                  // traditional object()
    verbose: option("-v", "--verbose")
  })
);

This improvement came from recognizing that many parsers ultimately produce objects, and artificially restricting merge() to only object() parsers was limiting composition patterns.

Context-aware help with longestMatch()

The new longestMatch() combinator selects the parser that consumes the most input tokens. This enables sophisticated help systems where command --help shows help for that specific command rather than global help:

const normalParser = object({
  help: constant(false),
  command: or(
    command("list", listOptions),
    command("add", addOptions)
  )
});

const contextualHelp = object({
  help: constant(true),
  commands: multiple(argument(string())),
  helpFlag: flag("--help")
});

const cli = longestMatch(normalParser, contextualHelp);

// myapp --help           → Shows global help
// myapp list --help      → Shows help for 'list' command
// myapp add --help       → Shows help for 'add' command

The run() functions in both @optique/core/facade and @optique/run now use this pattern automatically, so your CLI gets context-aware help without any additional configuration.

Version display support

Both @optique/core/facade and @optique/run now support version display through --version flags and version commands. See the runners documentation for details:

// @optique/run - simple API
run(parser, {
  version: "1.0.0",  // Adds --version flag
  help: "both"
});

// @optique/core/facade - detailed control
run(parser, "myapp", args, {
  version: {
    mode: "both",     // --version flag AND version command
    value: "1.0.0",
    onShow: process.exit
  }
});

The API follows the same pattern as help configuration, keeping things consistent and predictable.

Structured output functions

The new output functions in @optique/run provide consistent terminal formatting with automatic capability detection. Learn more in the messages documentation:

import { print, printError, createPrinter } from "@optique/run";
import { message } from "@optique/core/message";

// Standard output with automatic formatting
print(message`Processing ${filename}...`);

// Error output to stderr with optional exit
printError(message`File ${filename} not found`, { exitCode: 1 });

// Custom printer for specific needs
const debugPrint = createPrinter({
  stream: "stderr",
  colors: true,
  maxWidth: 80
});

debugPrint(message`Debug: ${details}`);

These functions automatically detect terminal capabilities and apply appropriate formatting, making your CLI output consistent across different environments.

Breaking changes

While we've tried to maintain backward compatibility, there are a few changes to be aware of:

  • The help option in @optique/run no longer accepts "none". Simply omit the option to disable help.
  • Custom parsers implementing getDocFragments() need to update their signature to use DocState<TState> instead of direct state values.
  • The object() parser now uses greedy parsing, attempting to consume all matching fields in one pass. This shouldn't affect most use cases but may change parsing order in complex scenarios.

Upgrading to 0.3.0

To upgrade to Optique 0.3.0, update both packages:

# Deno (JSR)
deno add @optique/core@^0.3.0 @optique/run@^0.3.0

# npm
npm update @optique/core @optique/run

# pnpm
pnpm update @optique/core @optique/run

# Yarn
yarn upgrade @optique/core @optique/run

# Bun
bun update @optique/core @optique/run

If you're only using the core package:

# Deno (JSR)
deno add @optique/core@^0.3.0

# npm
npm update @optique/core

Looking ahead

These improvements came from real-world usage and community feedback. We're particularly interested in hearing how the new dependent options patterns work for your use cases, and whether the context-aware help system meets your needs.

As always, you can find complete documentation at optique.dev and file issues or suggestions on GitHub.

洪 民憙 (Hong Minhee) :nonbinary:'s avatar
洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social

0.3.0 is out with dependent options and flexible parser composition, shaped by feedback from @z9mb1's work migrating @fedify CLI from Cliffy to Optique.

https://hackers.pub/@hongminhee/2025/optique-030

洪 民憙 (Hong Minhee)'s avatar
洪 民憙 (Hong Minhee)

@hongminhee@hackers.pub


We're releasing Optique 0.3.0 with several improvements that make building complex CLI applications more straightforward. This release focuses on expanding parser flexibility and improving the help system based on feedback from the community, particularly from the Fedify project's migration from Cliffy to Optique. Special thanks to @z9mb1 for her invaluable insights during this process.

What's new

  • Required Boolean flags with the new flag() parser for dependent options patterns
  • Flexible type defaults in withDefault() supporting union types for conditional CLI structures
  • Extended or() capacity now supporting up to 10 parsers (previously 5)
  • Enhanced merge() combinator that works with any object-producing parser, not just object()
  • Context-aware help using the new longestMatch() combinator
  • Version display support in both @optique/core and @optique/run
  • Structured output functions for consistent terminal formatting

Required Boolean flags with flag()

The new flag() parser creates Boolean flags that must be explicitly provided. While option() defaults to false when absent, flag() fails parsing entirely if not present. This subtle difference enables cleaner patterns for dependent options.

Consider a scenario where certain options only make sense when a mode is explicitly enabled:

import { flag, object, option, withDefault } from "@optique/core/parser";
import { integer } from "@optique/core/valueparser";

// Without the --advanced flag, these options aren't available
const parser = withDefault(
  object({
    advanced: flag("--advanced"),
    maxThreads: option("--threads", integer()),
    cacheSize: option("--cache-size", integer())
  }),
  { advanced: false as const }
);

// Usage:
// myapp                    → { advanced: false }
// myapp --advanced         → Error: --threads and --cache-size required
// myapp --advanced --threads 4 --cache-size 100 → Success

This pattern is particularly useful for confirmation flags (--yes-i-am-sure) or mode switches that fundamentally change how your CLI behaves.

Union types in withDefault()

Previously, withDefault() required the default value to match the parser's type exactly. Now it supports different types, creating union types that enable conditional CLI structures:

const conditionalParser = withDefault(
  object({
    server: flag("-s", "--server"),
    port: option("-p", "--port", integer()),
    host: option("-h", "--host", string())
  }),
  { server: false as const }
);

// Result type is now a union:
// | { server: false }
// | { server: true, port: number, host: string }

This change makes it much easier to build CLIs where different flags enable different sets of options, without resorting to complex or() chains.

More flexible merge() combinator

The merge() combinator now accepts any parser that produces object-like values. Previously limited to object() parsers, it now works with withDefault(), map(), and other transformative parsers:

const transformedConfig = map(
  object({
    host: option("--host", string()),
    port: option("--port", integer())
  }),
  ({ host, port }) => ({ endpoint: `${host}:${port}` })
);

const conditionalFeatures = withDefault(
  object({
    experimental: flag("--experimental"),
    debugLevel: option("--debug-level", integer())
  }),
  { experimental: false as const }
);

// Can now merge different parser types
const appConfig = merge(
  transformedConfig,        // map() result
  conditionalFeatures,      // withDefault() parser
  object({                  // traditional object()
    verbose: option("-v", "--verbose")
  })
);

This improvement came from recognizing that many parsers ultimately produce objects, and artificially restricting merge() to only object() parsers was limiting composition patterns.

Context-aware help with longestMatch()

The new longestMatch() combinator selects the parser that consumes the most input tokens. This enables sophisticated help systems where command --help shows help for that specific command rather than global help:

const normalParser = object({
  help: constant(false),
  command: or(
    command("list", listOptions),
    command("add", addOptions)
  )
});

const contextualHelp = object({
  help: constant(true),
  commands: multiple(argument(string())),
  helpFlag: flag("--help")
});

const cli = longestMatch(normalParser, contextualHelp);

// myapp --help           → Shows global help
// myapp list --help      → Shows help for 'list' command
// myapp add --help       → Shows help for 'add' command

The run() functions in both @optique/core/facade and @optique/run now use this pattern automatically, so your CLI gets context-aware help without any additional configuration.

Version display support

Both @optique/core/facade and @optique/run now support version display through --version flags and version commands. See the runners documentation for details:

// @optique/run - simple API
run(parser, {
  version: "1.0.0",  // Adds --version flag
  help: "both"
});

// @optique/core/facade - detailed control
run(parser, "myapp", args, {
  version: {
    mode: "both",     // --version flag AND version command
    value: "1.0.0",
    onShow: process.exit
  }
});

The API follows the same pattern as help configuration, keeping things consistent and predictable.

Structured output functions

The new output functions in @optique/run provide consistent terminal formatting with automatic capability detection. Learn more in the messages documentation:

import { print, printError, createPrinter } from "@optique/run";
import { message } from "@optique/core/message";

// Standard output with automatic formatting
print(message`Processing ${filename}...`);

// Error output to stderr with optional exit
printError(message`File ${filename} not found`, { exitCode: 1 });

// Custom printer for specific needs
const debugPrint = createPrinter({
  stream: "stderr",
  colors: true,
  maxWidth: 80
});

debugPrint(message`Debug: ${details}`);

These functions automatically detect terminal capabilities and apply appropriate formatting, making your CLI output consistent across different environments.

Breaking changes

While we've tried to maintain backward compatibility, there are a few changes to be aware of:

  • The help option in @optique/run no longer accepts "none". Simply omit the option to disable help.
  • Custom parsers implementing getDocFragments() need to update their signature to use DocState<TState> instead of direct state values.
  • The object() parser now uses greedy parsing, attempting to consume all matching fields in one pass. This shouldn't affect most use cases but may change parsing order in complex scenarios.

Upgrading to 0.3.0

To upgrade to Optique 0.3.0, update both packages:

# Deno (JSR)
deno add @optique/core@^0.3.0 @optique/run@^0.3.0

# npm
npm update @optique/core @optique/run

# pnpm
pnpm update @optique/core @optique/run

# Yarn
yarn upgrade @optique/core @optique/run

# Bun
bun update @optique/core @optique/run

If you're only using the core package:

# Deno (JSR)
deno add @optique/core@^0.3.0

# npm
npm update @optique/core

Looking ahead

These improvements came from real-world usage and community feedback. We're particularly interested in hearing how the new dependent options patterns work for your use cases, and whether the context-aware help system meets your needs.

As always, you can find complete documentation at optique.dev and file issues or suggestions on GitHub.