#Commander

洪 民憙 (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. ↩︎

Pekka Kallioniemi's avatar
Pekka Kallioniemi

@PekkaKallioniemi@defenseofliberty.social

Elon , a South African , unironically calling Mark - a senator, combat , and Shuttle - a '' is K-hole-level delusion.

Pekka Kallioniemi's avatar
Pekka Kallioniemi

@PekkaKallioniemi@defenseofliberty.social

Elon , a South African , unironically calling Mark - a senator, combat , and Shuttle - a '' is K-hole-level delusion.

GooglyEyesOnMagicCards's avatar
GooglyEyesOnMagicCards

@googlyeyesonmagiccards@mastodon.social

This match is real fun, thank in advance for giving it a watch.

youtu.be/VIy4I24mMz0

Dogmeat, Ever Loyal
ALT text detailsDogmeat, Ever Loyal
GooglyEyesOnMagicCards's avatar
GooglyEyesOnMagicCards

@googlyeyesonmagiccards@mastodon.social

This match is real fun, thank in advance for giving it a watch.

youtu.be/VIy4I24mMz0

Dogmeat, Ever Loyal
ALT text detailsDogmeat, Ever Loyal
Thorgrit Anvilforge's avatar
Thorgrit Anvilforge

@thorgrit@tabletop.social

Hi, I'm @mycroft@paws.town and this is my tabletop gaming alternate account. I primarily talk about and but lately I've gotten back into and other

I only play in-person, not online.

My usual pseudonym here is Thorgrit Anvilforge, dwarven battle-priest. Recently I've taken up Klattic of Tazgral, a minotaur sharpshooter I started playing in a game.

(info may change, was true at time of posting)