Skip to content

Resolver-style API for config file loading and value resolution #13

Description

@taras

Problem

Currently, consumers who want to support config file values must implement significant logic outside the parser:

  1. Parse once to extract --config path
  2. Load and parse the config file
  3. Parse again, passing file values
  4. Manually merge values when precedence logic is complex
  5. Handle edge cases (help detection, validation, source annotations)

This leads to parsing logic "spilling out" into consumer code, duplicating what the parser should own.

Current consumer code pattern

// Phase 1: Bootstrap parse to get config path
const bootstrap = buildBootstrapConfig();
const bootstrapResult = bootstrap.parse({ args: argv, envs: [] });
const configPath = bootstrapResult.value.config.config;

// Load config file externally
const fileConfig = configPath ? JSON.parse(yield* readFile(configPath)) : undefined;

// Phase 2: Parse again with file values
const fullParser = buildFullConfig();
const result = fullParser.parse({
  args: argv,
  envs: [{ name: "env", value: process.env }],
  values: fileConfig ? [{ name: "file", value: fileConfig }] : undefined,
});

// Manual merging for values consumed in phase 1
const watch = bootstrapResult.value.config.watch ?? fileConfig?.run?.watch;

Problems with this approach

  1. Two-phase parsing — Complex coordination between bootstrap and full parse
  2. Manual precedence — Consumer implements CLI > env > file logic
  3. Positional argument issues — Bootstrap must include positionals to parse flags after them (see Support parsing options after unknown positional arguments (permissive/interspersed mode) #11)
  4. Help position issues — Help behaves differently based on --help position (see Consistent help behavior regardless of --help position #12)
  5. No single source of truth — Final values are assembled from multiple parse results

Proposed: Resolver-style API

Similar to GraphQL resolvers, let consumers declare how to resolve values, while configliere handles when and precedence:

const parser = program({
  name: "testkit",
  config: commands({
    run: {
      ...object({
        config:  { description: "Config file",    ...field(z.string().optional()) },
        watch:   { description: "Watch mode",     ...field(z.boolean().optional()) },
        suite:   { description: "Test suite",     ...field(z.string()) },
        verbose: { description: "Verbose output", ...field(z.boolean().optional()) },
      }),
    },
  }),

  // Resolver tells configliere how to load file-based values
  resolvers: {
    file: {
      // Which parsed field contains the config path
      configPath: (parsed) => parsed.config,

      // How to load the file (generator for Effection)
      *load(path) {
        const content = yield* readFile(path, "utf-8");
        return JSON.parse(content);
      },

      // Config files are command-scoped: { "run": { ... } }
      scope: (fileContent, commandName) => fileContent[commandName],
    },
  },
});

// Single parse - configliere handles everything
const result = yield* parser.parse({
  args: argv,
  envs: process.env,
});

// result.value.config.watch   - already merged with correct precedence
// result.value.config.verbose - includes [file: verbose=true] annotation
// Help works consistently regardless of --help position

What configliere would handle internally

  1. Deferred file loading — Parse CLI args first, call resolver to load file, then merge
  2. Precedence — CLI > env > file > defaults (configurable)
  3. Source tracking — Annotations like [file: verbose=true] work automatically
  4. Help mode — Parse all options regardless of --help position, show sources in help
  5. Validation — Validate after all sources are merged

Benefits

  • Single parse call — no two-phase coordination
  • Parser owns precedence — no manual cliValue ?? fileValue merging
  • Consistent help — --help works the same regardless of position
  • Source annotations — work automatically for file-loaded values
  • Generator support — file loading uses Effection operations
  • Extensible — could support other resolvers (remote config, etc.)

Alternative: Simpler built-in config file option

If the full resolver API is too complex, a simpler built-in config file option could cover the common case:

const parser = program({
  name: "testkit",
  configFile: {
    flag: "config",   // Which field holds the path
    format: "json",   // Or "yaml", "toml", custom parser
    scoped: true,     // File uses { commandName: { ... } } structure
  },
  config: commands({ ... }),
});

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions