Skip to content

Piero Bosio Social Web Site Personale Logo Fediverso

Social Forum federato con il resto del mondo. Non contano le istanze, contano le persone

Stop writing if statements for your CLI flags

  • If you've built CLI tools, you've written code like this:

    if (opts.reporter === "junit" && !opts.outputFile) {
      throw new Error("--output-file is required for junit reporter");
    }
    if (opts.reporter === "html" && !opts.outputFile) {
      throw new Error("--output-file is required for html reporter");
    }
    if (opts.reporter === "console" && opts.outputFile) {
      console.warn("--output-file is ignored for console reporter");
    }
    

    A few months ago, I wrote Stop writing CLI validation. Parse it right the first time. about parsing individual option values correctly. But it didn't cover the relationships between options.

    In the code above, --output-file only makes sense when --reporter is junit or html. When it's console, the option shouldn't exist at all.

    We're using TypeScript. We have a powerful type system. And yet, here we are, writing runtime checks that the compiler can't help with. Every time we add a new reporter type, we need to remember to update these checks. Every time we refactor, we hope we didn't miss one.

    The state of TypeScript CLI parsers

    The old guard—Commander, yargs, minimist—were built before TypeScript became mainstream. They give you bags of strings and leave type safety as an exercise for the reader.

    But we've made progress. Modern TypeScript-first libraries like cmd-ts and Clipanion (the library powering Yarn Berry) take types seriously:

    // cmd-ts
    const app = command({
      args: {
        reporter: option({ type: string, long: 'reporter' }),
        outputFile: option({ type: string, long: 'output-file' }),
      },
      handler: (args) => {
        // args.reporter: string
        // args.outputFile: string
      },
    });
    
    // Clipanion
    class TestCommand extends Command {
      reporter = Option.String('--reporter');
      outputFile = Option.String('--output-file');
    }
    

    These libraries infer types for individual options. --port is a number. --verbose is a boolean. That's real progress.

    But here's what they can't do: express that --output-file is required when --reporter is junit, and forbidden when --reporter is console. The relationship between options isn't captured in the type system.

    So you end up writing validation code anyway:

    handler: (args) => {
      // Both cmd-ts and Clipanion need this
      if (args.reporter === "junit" && !args.outputFile) {
        throw new Error("--output-file required for junit");
      }
      // args.outputFile is still string | undefined
      // TypeScript doesn't know it's definitely string when reporter is "junit"
    }
    

    Rust's clap and Python's Click have requires and conflicts_with attributes, but those are runtime checks too. They don't change the result type.

    If the parser configuration knows about option relationships, why doesn't that knowledge show up in the result type?

    Modeling relationships with conditional()

    Optique treats option relationships as a first-class concept. Here's the test reporter scenario:

    import { conditional, object } from "@optique/core/constructs";
    import { option } from "@optique/core/primitives";
    import { choice, string } from "@optique/core/valueparser";
    import { run } from "@optique/run";
    
    const parser = conditional(
      option("--reporter", choice(["console", "junit", "html"])),
      {
        console: object({}),
        junit: object({
          outputFile: option("--output-file", string()),
        }),
        html: object({
          outputFile: option("--output-file", string()),
          openBrowser: option("--open-browser"),
        }),
      }
    );
    
    const [reporter, config] = run(parser);
    

    The conditional() combinator takes a discriminator option (--reporter) and a map of branches. Each branch defines what other options are valid for that discriminator value.

    TypeScript infers the result type automatically:

    type Result =
      | ["console", {}]
      | ["junit", { outputFile: string }]
      | ["html", { outputFile: string; openBrowser: boolean }];
    

    When reporter is "junit", outputFile is string—not string | undefined. The relationship is encoded in the type.

    Now your business logic gets real type safety:

    const [reporter, config] = run(parser);
    
    switch (reporter) {
      case "console":
        runWithConsoleOutput();
        break;
      case "junit":
        // TypeScript knows config.outputFile is string
        writeJUnitReport(config.outputFile);
        break;
      case "html":
        // TypeScript knows config.outputFile and config.openBrowser exist
        writeHtmlReport(config.outputFile);
        if (config.openBrowser) openInBrowser(config.outputFile);
        break;
    }
    

    No validation code. No runtime checks. If you add a new reporter type and forget to handle it in the switch, the compiler tells you.

    A more complex example: database connections

    Test reporters are a nice example, but let's try something with more variation. Database connection strings:

    myapp --db=sqlite --file=./data.db
    myapp --db=postgres --host=localhost --port=5432 --user=admin
    myapp --db=mysql --host=localhost --port=3306 --user=root --ssl
    

    Each database type needs completely different options:

    • SQLite just needs a file path
    • PostgreSQL needs host, port, user, and optionally password
    • MySQL needs host, port, user, and has an SSL flag

    Here's how you model this:

    import { conditional, object } from "@optique/core/constructs";
    import { withDefault, optional } from "@optique/core/modifiers";
    import { option } from "@optique/core/primitives";
    import { choice, string, integer } from "@optique/core/valueparser";
    
    const dbParser = conditional(
      option("--db", choice(["sqlite", "postgres", "mysql"])),
      {
        sqlite: object({
          file: option("--file", string()),
        }),
        postgres: object({
          host: option("--host", string()),
          port: withDefault(option("--port", integer()), 5432),
          user: option("--user", string()),
          password: optional(option("--password", string())),
        }),
        mysql: object({
          host: option("--host", string()),
          port: withDefault(option("--port", integer()), 3306),
          user: option("--user", string()),
          ssl: option("--ssl"),
        }),
      }
    );
    

    The inferred type:

    type DbConfig =
      | ["sqlite", { file: string }]
      | ["postgres", { host: string; port: number; user: string; password?: string }]
      | ["mysql", { host: string; port: number; user: string; ssl: boolean }];
    

    Notice the details: PostgreSQL defaults to port 5432, MySQL to 3306. PostgreSQL has an optional password, MySQL has an SSL flag. Each database type has exactly the options it needs—no more, no less.

    With this structure, writing dbConfig.ssl when the mode is sqlite isn't a runtime error—it's a compile-time impossibility.

    Try expressing this with requires_if attributes. You can't. The relationships are too rich.

    The pattern is everywhere

    Once you see it, you find this pattern in many CLI tools:

    Authentication modes:

    const authParser = conditional(
      option("--auth", choice(["none", "basic", "token", "oauth"])),
      {
        none: object({}),
        basic: object({
          username: option("--username", string()),
          password: option("--password", string()),
        }),
        token: object({
          token: option("--token", string()),
        }),
        oauth: object({
          clientId: option("--client-id", string()),
          clientSecret: option("--client-secret", string()),
          tokenUrl: option("--token-url", url()),
        }),
      }
    );
    

    Deployment targets, output formats, connection protocols—anywhere you have a mode selector that determines what other options are valid.

    Why conditional() exists

    Optique already has an or() combinator for mutually exclusive alternatives. Why do we need conditional()?

    The or() combinator distinguishes branches based on structure—which options are present. It works well for subcommands like git commit vs git push, where the arguments differ completely.

    But in the reporter example, the structure is identical: every branch has a --reporter flag. The difference lies in the flag's value, not its presence.

    // This won't work as intended
    const parser = or(
      object({ reporter: option("--reporter", choice(["console"])) }),
      object({ 
        reporter: option("--reporter", choice(["junit", "html"])),
        outputFile: option("--output-file", string())
      }),
    );
    

    When you pass --reporter junit, or() tries to pick a branch based on what options are present. Both branches have --reporter, so it can't distinguish them structurally.

    conditional() solves this by reading the discriminator's value first, then selecting the appropriate branch. It bridges the gap between structural parsing and value-based decisions.

    The structure is the constraint

    Instead of parsing options into a loose type and then validating relationships, define a parser whose structure is the constraint.

    Traditional approach Optique approach
    Parse → Validate → Use Parse (with constraints) → Use
    Types and validation logic maintained separately Types reflect the constraints
    Mismatches found at runtime Mismatches found at compile time

    The parser definition becomes the single source of truth. Add a new reporter type? The parser definition changes, the inferred type changes, and the compiler shows you everywhere that needs updating.

    Try it

    If this resonates with a CLI you're building:

    Next time you're about to write an if statement checking option relationships, ask: could the parser express this constraint instead?

    The structure of your parser is the constraint. You might not need that validation code at all.

  • hongminhee@hollo.socialundefined hongminhee@hollo.social shared this topic on

Gli ultimi otto messaggi ricevuti dalla Federazione
Post suggeriti
  • 0 Votes
    21 Posts
    13 Views
    @ghalldev
  • 0 Votes
    4 Posts
    6 Views
    @NovemDecimal the alt-text is ok. No worries.
  • 0 Votes
    1 Posts
    3 Views
    I recently added sync/async mode support to Optique, a type-safe CLI parser for TypeScript. It turned out to be one of the trickier features I've implemented—the object() combinator alone needed to compute a combined mode from all its child parsers, and TypeScript's inference kept hitting edge cases. What is Optique? Optique is a type-safe, combinatorial CLI parser for TypeScript, inspired by Haskell's optparse-applicative. Instead of decorators or builder patterns, you compose small parsers into larger ones using combinators, and TypeScript infers the result types. Here's a quick taste: import { object } from "@optique/core/constructs"; import { argument, option } from "@optique/core/primitives"; import { string, integer } from "@optique/core/valueparser"; import { run } from "@optique/run"; const cli = object({ name: argument(string()), count: option("-n", "--count", integer()), }); // TypeScript infers: { name: string; count: number | undefined } const result = run(cli); // sync by default The type inference works through arbitrarily deep compositions—in most cases, you don't need explicit type annotations. How it started Lucas Garron (@lgarron@mastodon.social) opened an issue requesting async support for shell completions. He wanted to provide <kbd>Tab</kbd>-completion suggestions by running shell commands like git for-each-ref to list branches and tags. // Lucas's example: fetching Git branches and tags in parallel const [branches, tags] = await Promise.all([ $`git for-each-ref --format='%(refname:short)' refs/heads/`.text(), $`git for-each-ref --format='%(refname:short)' refs/tags/`.text(), ]); At first, I didn't like the idea. Optique's entire API was synchronous, which made it simpler to reason about and avoided the “async infection” problem where one async function forces everything upstream to become async. I argued that shell completion should be near-instantaneous, and if you need async data, you should cache it at startup. But Lucas pushed back. The filesystem is a database, and many useful completions inherently require async work—Git refs change constantly, and pre-caching everything at startup doesn't scale for large repos. Fair point. What I needed to solve So, how do you support both sync and async execution modes in a composable parser library while maintaining type safety? The key requirements were: parse() returns T or Promise<T> complete() returns T or Promise<T> suggest() returns Iterable<T> or AsyncIterable<T> When combining parsers, if any parser is async, the combined result must be async Existing sync code should continue to work unchanged The fourth requirement is the tricky one. Consider this: const syncParser = flag("--verbose"); const asyncParser = option("--branch", asyncValueParser); // What's the type of this? const combined = object({ verbose: syncParser, branch: asyncParser }); The combined parser should be async because one of its fields is async. This means we need type-level logic to compute the combined mode. Five design options I explored five different approaches, each with its own trade-offs. Option A: conditional types with mode parameter Add a mode type parameter to Parser and use conditional types: type Mode = "sync" | "async"; type ModeValue<M extends Mode, T> = M extends "async" ? Promise<T> : T; interface Parser<M extends Mode, TValue, TState> { parse(context: ParserContext<TState>): ModeValue<M, ParserResult<TState>>; // ... } The challenge is computing combined modes: type CombineModes<T extends Record<string, Parser<any, any, any>>> = T[keyof T] extends Parser<infer M, any, any> ? M extends "async" ? "async" : "sync" : never; Option B: mode parameter with default value A variant of Option A, but place the mode parameter first with a default of "sync": interface Parser<M extends Mode = "sync", TValue, TState> { readonly $mode: M; // ... } The default value maintains backward compatibility—existing user code keeps working without changes. Option C: separate interfaces Define completely separate Parser and AsyncParser interfaces with explicit conversion: interface Parser<TValue, TState> { /* sync methods */ } interface AsyncParser<TValue, TState> { /* async methods */ } function toAsync<T, S>(parser: Parser<T, S>): AsyncParser<T, S>; Simpler to understand, but requires code duplication and explicit conversions. Option D: union return types for suggest() only The minimal approach. Only allow suggest() to be async: interface Parser<TValue, TState> { parse(context: ParserContext<TState>): ParserResult<TState>; // always sync suggest(context: ParserContext<TState>, prefix: string): Iterable<Suggestion> | AsyncIterable<Suggestion>; // can be either } This addresses the original use case but doesn't help if async parse() is ever needed. Option E: fp-ts style HKT simulation Use the technique from fp-ts to simulate Higher-Kinded Types: interface URItoKind<A> { Identity: A; Promise: Promise<A>; } type Kind<F extends keyof URItoKind<any>, A> = URItoKind<A>[F]; interface Parser<F extends keyof URItoKind<any>, TValue, TState> { parse(context: ParserContext<TState>): Kind<F, ParserResult<TState>>; } The most flexible approach, but with a steep learning curve. Testing the idea Rather than commit to an approach based on theoretical analysis, I created a prototype to test how well TypeScript handles the type inference in practice. I published my findings in the GitHub issue: Both approaches correctly handle the “any async → all async” rule at the type level. (…) Complex conditional types like ModeValue<CombineParserModes<T>, ParserResult<TState>> sometimes require explicit type casting in the implementation. This only affects library internals. The user-facing API remains clean. The prototype validated that Option B (explicit mode parameter with default) would work. I chose it for these reasons: Backward compatible: The default "sync" keeps existing code working Explicit: The mode is visible in both types and runtime (via a $mode property) Debuggable: Easy to inspect the current mode at runtime Better IDE support: Type information is more predictable How CombineModes works The CombineModes type computes whether a combined parser should be sync or async: type CombineModes<T extends readonly Mode[]> = "async" extends T[number] ? "async" : "sync"; This type checks if "async" is present anywhere in the tuple of modes. If so, the result is "async"; otherwise, it's "sync". For combinators like object(), I needed to extract modes from parser objects and combine them: // Extract the mode from a single parser type ParserMode<T> = T extends Parser<infer M, unknown, unknown> ? M : never; // Combine modes from all values in a record of parsers type CombineObjectModes<T extends Record<string, Parser<Mode, unknown, unknown>>> = CombineModes<{ [K in keyof T]: ParserMode<T[K]> }[keyof T][]>; Runtime implementation The type system handles compile-time safety, but the implementation also needs runtime logic. Each parser has a $mode property that indicates its execution mode: const syncParser = option("-n", "--name", string()); console.log(syncParser.$mode); // "sync" const asyncParser = option("-b", "--branch", asyncValueParser); console.log(asyncParser.$mode); // "async" Combinators compute their mode at construction time: function object<T extends Record<string, Parser<Mode, unknown, unknown>>>( parsers: T ): Parser<CombineObjectModes<T>, ObjectValue<T>, ObjectState<T>> { const parserKeys = Reflect.ownKeys(parsers); const combinedMode: Mode = parserKeys.some( (k) => parsers[k as keyof T].$mode === "async" ) ? "async" : "sync"; // ... implementation } Refining the API Lucas suggested an important refinement during our discussion. Instead of having run() automatically choose between sync and async based on the parser mode, he proposed separate functions: Perhaps run(…) could be automatic, and runSync(…) and runAsync(…) could enforce that the inferred type matches what is expected. So we ended up with: run(): automatic based on parser mode runSync(): enforces sync mode at compile time runAsync(): enforces async mode at compile time // Automatic: returns T for sync parsers, Promise<T> for async const result1 = run(syncParser); // string const result2 = run(asyncParser); // Promise<string> // Explicit: compile-time enforcement const result3 = runSync(syncParser); // string const result4 = runAsync(asyncParser); // Promise<string> // Compile error: can't use runSync with async parser const result5 = runSync(asyncParser); // Type error! I applied the same pattern to parse()/parseSync()/parseAsync() and suggest()/suggestSync()/suggestAsync() in the facade functions. Creating async value parsers With the new API, creating an async value parser for Git branches looks like this: import type { Suggestion } from "@optique/core/parser"; import type { ValueParser, ValueParserResult } from "@optique/core/valueparser"; function gitRef(): ValueParser<"async", string> { return { $mode: "async", metavar: "REF", parse(input: string): Promise<ValueParserResult<string>> { return Promise.resolve({ success: true, value: input }); }, format(value: string): string { return value; }, async *suggest(prefix: string): AsyncIterable<Suggestion> { const { $ } = await import("bun"); const [branches, tags] = await Promise.all([ $`git for-each-ref --format='%(refname:short)' refs/heads/`.text(), $`git for-each-ref --format='%(refname:short)' refs/tags/`.text(), ]); for (const ref of [...branches.split("\n"), ...tags.split("\n")]) { const trimmed = ref.trim(); if (trimmed && trimmed.startsWith(prefix)) { yield { kind: "literal", text: trimmed }; } } }, }; } Notice that parse() returns Promise.resolve() even though it's synchronous. This is because the ValueParser<"async", T> type requires all methods to use async signatures. Lucas pointed out this is a minor ergonomic issue. If only suggest() needs to be async, you still have to wrap parse() in a Promise. I considered per-method mode granularity (e.g., ValueParser<ParseMode, SuggestMode, T>), but the implementation complexity would multiply substantially. For now, the workaround is simple enough: // Option 1: Use Promise.resolve() parse(input) { return Promise.resolve({ success: true, value: input }); } // Option 2: Mark as async and suppress the linter // biome-ignore lint/suspicious/useAwait: sync implementation in async ValueParser async parse(input) { return { success: true, value: input }; } What it cost Supporting dual modes added significant complexity to Optique's internals. Every combinator needed updates: Type signatures grew more complex with mode parameters Mode propagation logic had to be added to every combinator Dual implementations were needed for sync and async code paths Type casts were sometimes necessary in the implementation to satisfy TypeScript For example, the object() combinator went from around 100 lines to around 250 lines. The internal implementation uses conditional logic based on the combined mode: if (combinedMode === "async") { return { $mode: "async" as M, // ... async implementation with Promise chains async parse(context) { // ... await each field's parse result }, }; } else { return { $mode: "sync" as M, // ... sync implementation parse(context) { // ... directly call each field's parse }, }; } This duplication is the cost of supporting both modes without runtime overhead for sync-only use cases. Lessons learned Listen to users, but validate with prototypes My initial instinct was to resist async support. Lucas's persistence and concrete examples changed my mind, but I validated the approach with a prototype before committing. The prototype revealed practical issues (like TypeScript inference limits) that pure design analysis would have missed. Backward compatibility is worth the complexity Making "sync" the default mode meant existing code continued to work unchanged. This was a deliberate choice. Breaking changes should require user action, not break silently. Unified mode vs per-method granularity I chose unified mode (all methods share the same sync/async mode) over per-method granularity. This means users occasionally write Promise.resolve() for methods that don't actually need async, but the alternative was multiplicative complexity in the type system. Designing in public The entire design process happened in a public GitHub issue. Lucas, Giuseppe, and others contributed ideas that shaped the final API. The runSync()/runAsync() distinction came directly from Lucas's feedback. Conclusion This was one of the more challenging features I've implemented in Optique. TypeScript's type system is powerful enough to encode the “any async means all async” rule at compile time, but getting there required careful design work and prototyping. What made it work: conditional types like ModeValue<M, T> can bridge the gap between sync and async worlds. You pay for it with implementation complexity, but the user-facing API stays clean and type-safe. Optique 0.9.0 with async support is currently in pre-release testing. If you'd like to try it, check out PR #70 or install the pre-release: npm add @optique/core@0.9.0-dev.212 @optique/run@0.9.0-dev.212 deno add --jsr @optique/core@0.9.0-dev.212 @optique/run@0.9.0-dev.212 Feedback is welcome!
  • 0 Votes
    4 Posts
    7 Views
    @oblomov When using `checked` or `hidden` I do like not to add any value.```<input type="checkbox" checked />```Here's an interesting take with link to the HTML spec:https://mastodon.social/@shinspiegel/115530101220632082