{
  "$type": "site.standard.document",
  "bskyPostRef": {
    "cid": "bafyreidcp4javsexpq6nryhlwzp4x5jkuhlokcyvxhkbjfn7zbx2gxpmma",
    "uri": "at://did:plc:wf5p6xnpnvp7jeg5htba4j4z/app.bsky.feed.post/3mjmqhkpa4za2"
  },
  "path": "/@hongminhee/2026/optique-10-discriminated-unions-for-cli",
  "publishedAt": "2026-04-16T15:30:34.062Z",
  "site": "https://hackers.pub",
  "tags": [
    "Optique",
    "Commander.js",
    "Commander",
    "Yargs",
    "JavaScript",
    "TypeScript",
    "CLI",
    "Node.js",
    "Node",
    "Deno",
    "Bun",
    "1.0.0",
    "@commander-js/extra-typings",
    "optparse-applicative",
    "Fedify",
    "Cliffy",
    "parse, don't validate",
    "optique.dev",
    "GitHub",
    "@commander-js",
    "@optique"
  ],
  "textContent": "You've probably written something like this in Commander.js.\n\n\n    import { Command, Option } from \"@commander-js/extra-typings\";\n\n    const program = new Command()\n      .addOption(\n        new Option(\"--token <token>\", \"API token\").conflicts([\n          \"username\", \"password\", \"oauthClientId\", \"oauthClientSecret\",\n        ]),\n      )\n      .addOption(\n        new Option(\"--username <username>\", \"Basic auth username\").conflicts([\n          \"token\", \"oauthClientId\", \"oauthClientSecret\",\n        ]),\n      )\n      .addOption(\n        new Option(\"--password <password>\", \"Basic auth password\").conflicts([\n          \"token\", \"oauthClientId\", \"oauthClientSecret\",\n        ]),\n      )\n      .addOption(\n        new Option(\"--oauth-client-id <id>\", \"OAuth client id\").conflicts([\n          \"token\", \"username\", \"password\",\n        ]),\n      )\n      .addOption(\n        new Option(\"--oauth-client-secret <secret>\", \"OAuth client secret\")\n          .conflicts([\"token\", \"username\", \"password\"]),\n      );\n\n    program.parse();\n    const options = program.opts();\n\nIt compiles. It runs. Commander.js rejects `--token abc --username alice` with the conflict error you'd expect.\n\nLook at what TypeScript thinks `options` is, though.\n\n\n    {\n      token?: string | undefined;\n      username?: string | undefined;\n      password?: string | undefined;\n      oauthClientId?: string | undefined;\n      oauthClientSecret?: string | undefined;\n    }\n\nFive 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.\n\n\n    if (options.token != null) {\n      useBasicAuth(options.username!);\n    }\n\nThe 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.\n\nI'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.\n\n## Runtime checks aren't type-level knowledge\n\nThe 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.\n\nOn 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.\n\nCommander.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.\n\nCommander.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.\n\nYargs 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:\n\n\n    Missing dependent arguments:\n     username -> password\n\nThat'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.\n\nThe Yargs result type is worth seeing as well:\n\n\n    {\n      [x: string]: unknown;\n      oauthClientId: string | undefined;\n      \"oauth-client-id\": string | undefined;\n      // …the other options, each of them in both kebab- and camel-cased forms\n    }\n\nThe 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.\n\nThis 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.\n\n## Types that know which branch you picked\n\nHere's the same CLI in Optique.\n\n\n    import { object, or } from \"@optique/core/constructs\";\n    import { constant, option } from \"@optique/core/primitives\";\n    import { string } from \"@optique/core/valueparser\";\n    import { run } from \"@optique/run\";\n\n    const parser = or(\n      object({\n        auth: constant(\"token\" as const),\n        token: option(\"--token\", string({ metavar: \"TOKEN\" })),\n      }),\n      object({\n        auth: constant(\"basic\" as const),\n        username: option(\"--username\", string({ metavar: \"USER\" })),\n        password: option(\"--password\", string({ metavar: \"PASS\" })),\n      }),\n      object({\n        auth: constant(\"oauth\" as const),\n        clientId: option(\"--oauth-client-id\", string({ metavar: \"ID\" })),\n        clientSecret: option(\"--oauth-client-secret\", string({ metavar: \"SECRET\" })),\n      }),\n    );\n\n    const parsed = run(parser);\n\nThe 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.\n\nThe type `parsed` gets is:\n\n\n      | { readonly auth: \"token\"; readonly token: string }\n      | { readonly auth: \"basic\"; readonly username: string; readonly password: string }\n      | { readonly auth: \"oauth\"; readonly clientId: string; readonly clientSecret: string }\n\nThat's a discriminated union. When you consume the parsed value, TypeScript knows which fields are available based on `auth`:\n\n\n    switch (parsed.auth) {\n      case \"token\":\n        await callApiWithToken(parsed.token);\n        break;\n      case \"basic\":\n        await callApiWithBasic(parsed.username, parsed.password);\n        break;\n      case \"oauth\":\n        await callApiWithOauth(parsed.clientId, parsed.clientSecret);\n        break;\n    }\n\nInside 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.\n\nThe 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.\n\nI 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.\n\nI 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.\n\n## CLI arguments are one place values come from; there are others\n\nSo 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.\n\nOn 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.\n\nIn 1.0, Optique treats these four sources as one problem.\n\n## One parser, four sources\n\nTake 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).\n\nHere's how you'd wire that up in Optique 1.0.\n\n\n    import { object, or } from \"@optique/core/constructs\";\n    import { constant, option } from \"@optique/core/primitives\";\n    import { string } from \"@optique/core/valueparser\";\n    import { bindEnv, createEnvContext } from \"@optique/env\";\n    import { bindConfig, createConfigContext } from \"@optique/config\";\n    import { prompt } from \"@optique/inquirer\";\n    import { runAsync } from \"@optique/run\";\n    import { z } from \"zod\";\n\n    const envCtx = createEnvContext({ prefix: \"MYAPP_\" });\n    const cfgCtx = createConfigContext({\n      schema: z.object({\n        oauth: z.object({\n          clientId: z.string().optional(),\n          clientSecret: z.string().optional(),\n        }).optional(),\n      }),\n    });\n\n    const parser = or(\n      object({\n        auth: constant(\"token\" as const),\n        token: bindEnv(\n          option(\"--token\", string()),\n          { context: envCtx, key: \"TOKEN\", parser: string() },\n        ),\n      }),\n      object({\n        auth: constant(\"basic\" as const),\n        username: option(\"--username\", string()),\n        password: prompt(\n          option(\"--password\", string()),\n          { type: \"password\", message: \"Password:\", mask: true },\n        ),\n      }),\n      object({\n        auth: constant(\"oauth\" as const),\n        clientId: bindConfig(\n          option(\"--oauth-client-id\", string()),\n          { context: cfgCtx, key: (c) => c?.oauth?.clientId },\n        ),\n        clientSecret: bindConfig(\n          option(\"--oauth-client-secret\", string()),\n          { context: cfgCtx, key: (c) => c?.oauth?.clientSecret },\n        ),\n      }),\n    );\n\n    const parsed = await runAsync(parser, { contexts: [envCtx, cfgCtx] });\n\nThe 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.\n\nResolution 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.\n\nThe 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.\n\nA 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.\n\nThe “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.\n\n## Constraints that don't leak\n\nThere's a subtler problem that I didn't fully appreciate until late in the 0.x cycle. Consider this:\n\n\n    option(\"--port\", integer({ min: 1024, max: 65535 }))\n\nAt the CLI, the parser rejects `--port 80`. Good. Now wrap it in `bindEnv()`:\n\n\n    bindEnv(\n      option(\"--port\", integer({ min: 1024, max: 65535 })),\n      { context: envCtx, key: \"PORT\", parser: integer() },\n    )\n\nIn 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.\n\nThis 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.\n\n1.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.\n\nI'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.\n\n## When Optique isn't the right choice\n\nIf 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.\n\nIf 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.\n\nIf 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.\n\nThere'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.\n\nI 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.\n\n## 1.0 means I can stop adding footnotes\n\nThrough 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.\n\nThat's what 1.0 changes for me. I can send someone the docs link without a page of caveats first.\n\nDocumentation 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.\n\n* * *\n\n  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. ↩︎\n\n\n",
  "title": "From five optional fields to a discriminated union: CLI parsing with Optique 1.0"
}