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

洪 民憙 (Hong Minhee) :nonbinary:

@hongminhee@hollo.social · 1058 following · 1881 followers

An intersectionalist, feminist, and socialist living in Seoul (UTC+09:00). @tokolovesme's spouse. Who's behind @fedify, @hollo, and @botkit. Write some free software in , , , & . They/them.

서울에 사는 交叉女性主義者이자 社會主義者. 金剛兔(@tokolovesme)의 配偶者. @fedify, @hollo, @botkit 메인테이너. , , , 等으로 自由 소프트웨어 만듦.

()

Lobsters's avatar
Lobsters

@lobsters@mastodon.social

From five optional fields to a discriminated union: CLI parsing with Optique 1.0 by @hongminhee lobste.rs/s/kvf7u1
hackers.pub/@hongminhee/2026/o

洪 民憙 (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)'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

BOJ (Baekjoon Online Judge), a big competitive programming site in Korea, says it's shutting down on April 28. It has been around since 2010 and, as far as I know, has mostly been one person's work the whole time. The notice doesn't say why. The rumor is that the server bills finally got too high.

What caught my eye is that some people in the Korean , including @2chanhaeng, are already talking about whether a federated replacement could work, with coordinating things and volunteer nodes doing the judging. I have no idea if that can really work, since timing differences between machines are a serious problem in competitive programming, and I'm not the right person to help with it. Still, I like that the first reaction was to try building something.

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

@hongminhee@hollo.social

BOJ (Baekjoon Online Judge), a big competitive programming site in Korea, says it's shutting down on April 28. It has been around since 2010 and, as far as I know, has mostly been one person's work the whole time. The notice doesn't say why. The rumor is that the server bills finally got too high.

What caught my eye is that some people in the Korean , including @2chanhaeng, are already talking about whether a federated replacement could work, with coordinating things and volunteer nodes doing the judging. I have no idea if that can really work, since timing differences between machines are a serious problem in competitive programming, and I'm not the right person to help with it. Still, I like that the first reaction was to try building something.

Lobsters's avatar
Lobsters

@lobsters@mastodon.social

The Fediverse deserves a dumb graphical client via @aks lobste.rs/s/7lmezq
adele.pages.casa/md/blog/the-f

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

@hongminhee@hollo.social

If you tend to win most arguments, you're probably either very smart or very self-righteous. And since the two aren't mutually exclusive, both can be true at the same time.

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

@hongminhee@hollo.social

The for the Fediverse & Social Web track at COSCUP 2026 (Taipei, Aug 8–9) is now open! If you're working on , the , or anything in the open social web space, we'd love to hear from you. The deadline is May 9. is free to attend.

👉 https://hackers.pub/@fedidevkr/2026/fediverse-social-web-track-at-coscup-2026-cfp

(Boosts appreciated!)

FediDev KR (한국 연합우주 개발자 모임)'s avatar
FediDev KR (한국 연합우주 개발자 모임)

@fedidevkr@hackers.pub

Read it in other languages: 日本語 (Japanese), 한국어 (Korean).


FediDev KR and FediLUG (Japan) are pleased to announce the Fediverse & Social Web track at COSCUP 2026, and invite participants to submit proposals for talks.

COSCUP (Conference for Open Source Coders, Users, and Promoters) is a free, community-run open source conference held annually in Taipei, Taiwan. Think FOSDEM, but in East Asia. This year it takes place August 8–9 at the National Taiwan University of Science and Technology, and is co-hosted with UbuCon Asia 2026.

The Fediverse & Social Web track runs for a full day, six hours in total. It is the first dedicated fediverse track at a major open source conference in East Asia, and we hope it becomes a regular gathering point for the fediverse community in the region.

Format

The default talk length is 30 minutes. If you need more or less time, note your preferred length when submitting.

Topics

We welcome proposals on anything related to the fediverse and the open social web, including:

  • Implementations of ActivityPub or related protocols
  • Clients for ActivityPub-enabled software
  • Libraries, toolkits, and frameworks for fediverse development
  • Supporting services: search, onboarding, moderation tooling
  • Instance administration and operations
  • Governance, policy, and the social dimensions of running federated communities
  • The broader open social web and interoperability

Important dates

  • Submission opens: March 28, 2026
  • Submission deadline: May 9, 2026 (AoE)
  • Acceptance notifications: June 9, 2026
  • Conference: August 8–9, 2026

Submissions

Submit proposals at https://pretalx.coscup.org/coscup-2026/cfp. Select Fediverse & Social Web from the track dropdown.

You can write your proposal in English or Chinese. COSCUP publishes session descriptions bilingually in English and Chinese, but that translation happens after acceptance; you don't need to provide both languages when submitting.

All sessions will be recorded and released under CC BY-SA 4.0. If your talk contains material that cannot be recorded or released under those terms, please note this in your submission.

Code of conduct

All speakers and attendees are expected to follow the COSCUP Code of Conduct.

Contact

Questions about the track, topics, or the fediverse in general are welcome at contact@fedidev.kr or @fedidevkr on the fediverse.

FediDev KR (한국 연합우주 개발자 모임)'s avatar
FediDev KR (한국 연합우주 개발자 모임)

@fedidevkr@hackers.pub

Read it in other languages: 日本語 (Japanese), 한국어 (Korean).


FediDev KR and FediLUG (Japan) are pleased to announce the Fediverse & Social Web track at COSCUP 2026, and invite participants to submit proposals for talks.

COSCUP (Conference for Open Source Coders, Users, and Promoters) is a free, community-run open source conference held annually in Taipei, Taiwan. Think FOSDEM, but in East Asia. This year it takes place August 8–9 at the National Taiwan University of Science and Technology, and is co-hosted with UbuCon Asia 2026.

The Fediverse & Social Web track runs for a full day, six hours in total. It is the first dedicated fediverse track at a major open source conference in East Asia, and we hope it becomes a regular gathering point for the fediverse community in the region.

Format

The default talk length is 30 minutes. If you need more or less time, note your preferred length when submitting.

Topics

We welcome proposals on anything related to the fediverse and the open social web, including:

  • Implementations of ActivityPub or related protocols
  • Clients for ActivityPub-enabled software
  • Libraries, toolkits, and frameworks for fediverse development
  • Supporting services: search, onboarding, moderation tooling
  • Instance administration and operations
  • Governance, policy, and the social dimensions of running federated communities
  • The broader open social web and interoperability

Important dates

  • Submission opens: March 28, 2026
  • Submission deadline: May 9, 2026 (AoE)
  • Acceptance notifications: June 9, 2026
  • Conference: August 8–9, 2026

Submissions

Submit proposals at https://pretalx.coscup.org/coscup-2026/cfp. Select Fediverse & Social Web from the track dropdown.

You can write your proposal in English or Chinese. COSCUP publishes session descriptions bilingually in English and Chinese, but that translation happens after acceptance; you don't need to provide both languages when submitting.

All sessions will be recorded and released under CC BY-SA 4.0. If your talk contains material that cannot be recorded or released under those terms, please note this in your submission.

Code of conduct

All speakers and attendees are expected to follow the COSCUP Code of Conduct.

Contact

Questions about the track, topics, or the fediverse in general are welcome at contact@fedidev.kr or @fedidevkr on the fediverse.

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

Emelia 👸🏻's avatar
Emelia 👸🏻

@thisismissem@hachyderm.io

Contrary to seemingly popular belief, FediMod FIRES and the protocol I created for it is so much more than just "blocklist synchronisation"

It's designed to give moderation teams choice in what they pull from providers and how that's applied to their server. It's designed to give finer-grained moderation control than "silence or suspend" across a wide array of entities.

Thinking of FediMod FIRES as just a blocklist synchronisation tool is like thinking of a computer as a just a modern typewriter. Sure you can just do what that original thing did, but it's capable of so much more.

For example, it's possible to share moderation data on individual bad actors and also on email domains (currently not in the shipped version but pretty straightforward to add). In the future it'll also support more entity types like Hashtags, Links, and possibly image hashes.

Domain blocks are just the tip of the iceberg of what FediMod FIRES can do.

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

@hongminhee@hackers.pub

1.0.0을 릴리스했습니다.

Haskell의 optparse-applicative 스타일의 파서가 필요해서 만들었습니다. 작은 타입 파서들을 조합하면 TypeScript가 결과 타입을 자동으로 추론합니다. 서브커맨드, 옵션 간 의존성, 셸 완성, 맨(man) 페이지 생성 등을 지원하고, , .js, 등에서 동작합니다.

이번 버전에서는 @optique/env (환경 변수 폴백) 패키지와 @optique/inquirer (Inquirer.js 프롬프트 폴백) 패키지를 추가했습니다. API의 어색한 부분들도 많이 정리하고, 다섯 가지 셸의 완성 스크립트 버그도 한꺼번에 잡았습니다.

JSR과 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

Stefan Bohacek's avatar
Stefan Bohacek

@stefan@stefanbohacek.online

Nearly 10,000 organizations, institutions, companies, and other official groups with a fediverse presence recorded on Wikidata!

data.stefanbohacek.com/project

Mastodon's avatar
Mastodon

@Mastodon@mastodon.social

We’re happy to share that Mastodon has been awarded a service agreement from the Sovereign Tech Fund @sovtechfund 🎉

This covers five major initiatives through 2026 and 2027. We are very grateful for this support. Read about the details in our blog post.

blog.joinmastodon.org/2026/04/

Christian Kruse's avatar
Christian Kruse

@cjk@chaos.social

I think I'm pretty near a beta release for Gitte (a GNOME Git GUI). While I have plans for the future, I think the basics are covered (besides pull - I want to have that for the first release). What do you people think?

codeberg.org/ckruse/Gitte

kopper :colon_three:'s avatar
kopper :colon_three:

@kopper@not-brain.d.on-t.work · Reply to kopper :colon_three:'s post

pleroma used AS before mastodon switched over to activitypub. i think they have a solid claim there
kopper :colon_three:'s avatar
kopper :colon_three:

@kopper@not-brain.d.on-t.work · Reply to kopper :colon_three:'s post

no nvm i shouldve said AS is just "the pleroma database schema" that wouldve been funnier
kopper :colon_three:'s avatar
kopper :colon_three:

@kopper@not-brain.d.on-t.work

extremely funny how the DID spec is a whole W3C thing but it's only claim to fame is "the bluesky user id number" like how activitystreams is a whole W3C thing but it's only claim to fame is "the mastodon post export format"
Evan Prodromou's avatar
Evan Prodromou

@evanprodromou@socialwebfoundation.org

The call for proposals is open for the COSCUP Fediverse track in Taipei, Taiwan. ActivityPub-related software, including server and client implementations, are great topics for the event. COSCUP ("Conference for Open Source Coders, Users, and Promoters") is the FOSDEM of East Asia. Run by the Open Source community in Taiwan, it brings together people excited about FOSS across the region. For the first time, this year, members of the Korean ActivityPub developer community FediDev KR are […]

The call for proposals is open for the COSCUP Fediverse track in Taipei, Taiwan. ActivityPub-related software, including server and client implementations, are great topics for the event.

COSCUP (“Conference for Open Source Coders, Users, and Promoters”) is the FOSDEM of East Asia. Run by the Open Source community in Taiwan, it brings together people excited about FOSS across the region.

For the first time, this year, members of the Korean ActivityPub developer community FediDev KR are joining up with FediLUG of Japan to program and run a Fediverse track at COSCUP. This has the potential to be a huge step forward for the Fediverse developer community. Although many major projects, like Fedify and Misskey, are created and promoted in East Asia, distance and language barriers make it hard for East Asian devs to participate in European and North American in-person events.

The Fediverse track is open to proposals about ActivityPub implementations, clients for ActivityPub platforms, ancillary services, libraries and toolkits. But also, as at FOSDEM, talks about the human aspects of Fediverse technology, like moderation, policy and governance, are welcome and encouraged. This event looks like it will cover as much interesting conceptual space as its twin at FOSDEM.

Hong Minhee, hongminhee@hollo.social, was one of the main speakers at FOSDEM’s Social Web devroom this year. Their talk about Fedify was important, but even more important was their effort to bridge the gap between Asia’s and Europe’s Fediverse development communities.

I (Evan) hope that COSCUP brings together many Asian developers, but I also hope that North American and European individuals and teams put in proposals as well. Knitting together these two important communities on the Fediverse requires effort from both sides. That’s why I’m applying to speak (about ActivityPub 1.1), and why I hope to see many familiar faces among the new ones in Taiwan.

폰지사기 인간's avatar
폰지사기 인간

@makanomoyaki@qdon.space

GIMP 창시자들이 CockroachDB 메인테이너라는 사실을 믿지 못하는 펀

Chris Ammerman's avatar
Chris Ammerman

@cammerman@mstdn.social

Hey. Nations are made up. Citizenship is made up. Naturalization is made up. We have these things because this is the social contract we grew over hundreds of years.

The idea of an immigrant becoming a citizen is no less legitimate than the idea of someone being born a citizen. They are both and equally legal constructs that exist because things were worse without them.

If an immigrant can be denaturalized, then anyone the government wants to be rid of can be denaturalized.

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

@hongminhee@hollo.social · Reply to jnkrtech's post

@jnkrtech On CJK hashtags from scratch, I honestly don't know. My suspicion is that hashtags may be the wrong unit in the first place. Some kind of topic grouping might fit better. But once you go there, you're getting uncomfortably close to a recommender, and that's exactly the sort of thing a lot of people came here to get away from.

The ML idea is interesting, but I think it would be a very hard sell even if it were strictly opt-in. On the fediverse, once you put “ML” on the label, half the objections start writing themselves.

I think the bigger issue is that hashtags are partly compensating for weak search. And weak search is not just a bug. In some corners it is very much the point. A lot of fediverse people want discovery to stay limited because it keeps spaces smaller and less searchable by strangers. So this stops being just a technical proposal pretty quickly. It turns into an argument about what the fediverse is for.

500 Internal Server Error's avatar
500 Internal Server Error

@bootlegrydia@treehouse.systems

btw, many western linguistic features were still introduced into Chinese starting in the 1800s due to the influence of western colonizers:

  • Writing direction: horizontal, left-to-right, rather than vertical, right-to-left

  • Punctuation: The adoption of standardized punctuation mark

  • She/Her pronouns.

500 Internal Server Error's avatar
500 Internal Server Error

@bootlegrydia@treehouse.systems

RE: fosstodon.org/@nicemicro/11639

textbook example of western-centric arrogance of western cis white dudes, Number #232123

Servo's avatar
Servo

@servo@floss.social

Servo 0.1.0 is out! 🚀
This is our first release available in crates.io and our first LTS version
servo.org/blog/2026/04/13/serv

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

@hongminhee@hollo.social · Reply to NiceMicro's post

@nicemicro Fair point on the particles. I was too narrow there.

On spaces, Chinese and Japanese did not end up without them out of stubbornness. They developed ways of reading that do not depend on spaces the way Latin-script languages often do. Most of the research people cite on spacing and reading speed comes from alphabetic languages, so it does not automatically apply to CJK. Treating that as “stubbornness” just assumes Latin-script conventions are the norm.

The hashtag friction is real regardless.

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

@hongminhee@hollo.social · Reply to marius's post

@mariusor I'm thinking about how to have users input them! Yeah, rendering them is relatively straightforward.

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

@hongminhee@hollo.social · Reply to 500 Internal Server Error's post

@bootlegrydia That sounds a good idea! I definitely will implement that syntax in the platforms I'm building!

500 Internal Server Error's avatar
500 Internal Server Error

@bootlegrydia@treehouse.systems · Reply to 洪 民憙 (Hong Minhee) :nonbinary:'s post

@hongminhee weibo solved this issue by adding a hash at the end of the tag, ie. # instead of

however western platforms definitely won't do this

Older →