洪 民憙 (Hong Minhee) 
@hongminhee@hollo.social · Reply to 洪 民憙 (Hong Minhee) :nonbinary:'s post
個人的に国漢文混用体(漢字ハングル混じり文)を好んで使う立場から、韓国の漢字教育に関する自分の考えを書いてみました。
https://writings.hongminhee.org/2026/04/hanja-education/index.ja.html


@hongminhee@hollo.social · 1059 following · 1880 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 #TypeScript, #Haskell, #Rust, & #Python. They/them.
서울에 사는 交叉女性主義者이자 社會主義者. 金剛兔(@tokolovesme)의 配偶者. @fedify, @hollo, @botkit 메인테이너. #TypeScript, #Haskell, #Rust, #Python 等으로 自由 소프트웨어 만듦.
| Website | GitHub | Blog | Hackers' Pub |
|---|---|---|---|

@hongminhee@hollo.social · Reply to 洪 民憙 (Hong Minhee) :nonbinary:'s post
個人的に国漢文混用体(漢字ハングル混じり文)を好んで使う立場から、韓国の漢字教育に関する自分の考えを書いてみました。
https://writings.hongminhee.org/2026/04/hanja-education/index.ja.html

@hongminhee@hollo.social
個人的으로 國漢文混用體를 즐겨 쓰는 立場에서, 韓國의 漢字 敎育에 關한 제 생각을 글로 써봤습니다.

@hongminhee@hollo.social · Reply to 洪 民憙 (Hong Minhee) :nonbinary:'s post
Commander.jsの.conflicts()や.implies()は、排他的な組み合わせをランタイムではちゃんと検出してくれます。
でも.opts()の型は賢くならず、戻り値は結局string | undefinedのままです。どのオプションが同時に使えないのかを、TypeScriptは知りません。
このズレをパーサーコンビネータでどう型に落とし込めるか、Yargsとの比較も含めて書きました。後半では、環境変数・設定ファイル・対話プロンプトまで同じ型保証を広げる話もしています。
@lobsters@mastodon.social
From five optional fields to a discriminated union: CLI parsing with Optique 1.0 by @hongminhee https://lobste.rs/s/kvf7u1 #api #javascript
https://hackers.pub/@hongminhee/2026/optique-10-discriminated-unions-for-cli

@hongminhee@hollo.social
I wrote about a problem that's been bugging me with #Commander.js and #Yargs: .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 #TypeScript 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
@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.
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.
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.
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.
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.
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.
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.
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.
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. ↩︎
@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.
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.
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.
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.
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.
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.
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.
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.
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. ↩︎

@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 #fediverse, including @2chanhaeng, are already talking about whether a federated replacement could work, with #ActivityPub 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.

@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 #fediverse, including @2chanhaeng, are already talking about whether a federated replacement could work, with #ActivityPub 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@mastodon.social
The Fediverse deserves a dumb graphical client via @aks https://lobste.rs/s/7lmezq #web
https://adele.pages.casa/md/blog/the-fediverse-deserves-a-dumb-graphical-client.md

@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.

@hongminhee@hollo.social
The #CFP for the Fediverse & Social Web track at COSCUP 2026 (Taipei, Aug 8–9) is now open! If you're working on #ActivityPub, the #fediverse, or anything in the open social web space, we'd love to hear from you. The deadline is May 9. #COSCUP is free to attend.
👉 https://hackers.pub/@fedidevkr/2026/fediverse-social-web-track-at-coscup-2026-cfp
(Boosts appreciated!)
@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.
The default talk length is 30 minutes. If you need more or less time, note your preferred length when submitting.
We welcome proposals on anything related to the fediverse and the open social web, including:
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.
All speakers and attendees are expected to follow the COSCUP Code of Conduct.
Questions about the track, topics, or the fediverse in general are welcome at contact@fedidev.kr or @fedidevkr on the fediverse.
@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.
The default talk length is 30 minutes. If you need more or less time, note your preferred length when submitting.
We welcome proposals on anything related to the fediverse and the open social web, including:
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.
All speakers and attendees are expected to follow the COSCUP Code of Conduct.
Questions about the track, topics, or the fediverse in general are welcome at contact@fedidev.kr or @fedidevkr on the fediverse.

@hongminhee@hollo.social
#Optique 1.0.0 is out! If you build #CLI tools with #TypeScript, 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 #Deno, #Node.js, and #Bun.
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.
@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.
@hongminhee@hackers.pub
#Optique 1.0.0을 릴리스했습니다.
Haskell의 optparse-applicative 스타일의 #TypeScript #CLI 파서가 필요해서 만들었습니다. 작은 타입 파서들을 조합하면 TypeScript가 결과 타입을 자동으로 추론합니다. 서브커맨드, 옵션 간 의존성, 셸 완성, 맨(man) 페이지 생성 등을 지원하고, #Deno, #Node.js, #Bun 등에서 동작합니다.
이번 버전에서는 @optique/env (환경 변수 폴백) 패키지와 @optique/inquirer (Inquirer.js 프롬프트 폴백) 패키지를 추가했습니다. API의 어색한 부분들도 많이 정리하고, 다섯 가지 셸의 완성 스크립트 버그도 한꺼번에 잡았습니다.
JSR과 npm에서 설치하실 수 있습니다.

@hongminhee@hollo.social
#Optique 1.0.0 is out! If you build #CLI tools with #TypeScript, 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 #Deno, #Node.js, and #Bun.
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.
@stefan@stefanbohacek.online
Nearly 10,000 organizations, institutions, companies, and other official groups with a fediverse presence recorded on Wikidata!
@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.
https://blog.joinmastodon.org/2026/04/sovereign-tech-agency-funding/
@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?

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

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

@kopper@not-brain.d.on-t.work
@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 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.
@makanomoyaki@qdon.space
GIMP 창시자들이 CockroachDB 메인테이너라는 사실을 믿지 못하는 펀
@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.

@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.
@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.
@bootlegrydia@treehouse.systems
RE: https://fosstodon.org/@nicemicro/116397437022885270
textbook example of western-centric arrogance of western cis white dudes, Number #232123
@nicemicro@fosstodon.org · Reply to 洪 民憙 (Hong Minhee) :nonbinary:'s post
@hongminhee I mean it isn't only Korean that has particles modifying words, like look at any Romance or Slavic language.
On the lack of spaces... whell what can you do when some cultures are too stubborn to use a thousand year old invention that provably improves reading speed and comprehension? Hashtags are the least of their issues.
@servo@floss.social
Servo 0.1.0 is out! 🚀
This is our first release available in crates.io and our first LTS version
https://servo.org/blog/2026/04/13/servo-0.1.0-release/

@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.