Skip to content

Instantly share code, notes, and snippets.

@cowboyd
Created September 25, 2026 03:25
Show Gist options
  • Select an option

  • Save cowboyd/eb8ce14699d03a1f933f62b928f1d4ce to your computer and use it in GitHub Desktop.

Select an option

Save cowboyd/eb8ce14699d03a1f933f62b928f1d4ce to your computer and use it in GitHub Desktop.
Conversation with GPT about configliere multiple()

Unpack this for me. Was it like this before? What changed to make it this way. What is an example of the degenerative case?

I see one concern worth addressing: candidates() computes the Cartesian product of decoder candidates. Numeric-looking strings generally have multiple interpretations, so an invalid array schema could cause exponential validation work as repetitions increase. The generator makes successful cases lazy, but a failure may exhaust all (n^k) combinations. I’d either document why candidate counts are safely bounded or add a stress-oriented test/design limit.

did this change? again, unpack and examine

There’s also a smaller diagnostic edge: if several successful occurrences accumulate warnings and a later occurrence fails, the failure settles the parameter immediately and the earlier accumulated issues appear to be dropped. That may matter once successful reads produce meaningful diagnostics. ▶ Thought

▶ ✶ Thinking

Analyzing git commits and decoder candidates

I’ll trace both behaviors through the pre-fix and post-fix implementations, then construct concrete argv/schema examples. I’ll distinguish newly introduced behavior from behavior that merely moved layers.

▶ Thought, ran 3 commands

▶ ✓ Command git show 397d572ad720ec5bdb87b20f1b054e1e3eef6798:lib/bind.ts | sed -n '1,360p'; git show 73008c2af27f6c59d85a3315023c61eb5a5f1851:lib/bind.ts | sed -n '80,350p'; sed -n '1,180p' lib/decode.ts; sed -n '1,180p' lib/result.ts; git log --oneline --reverse -- lib/bind.ts lib/read.ts lib/param.ts

git show 397d572ad720ec5bdb87b20f1b054e1e3eef6798:lib/bind.ts | sed -n '1,360p'; git show 73008c2af27f6c59d85a3315023c61eb5a5f1851:lib/bind.ts | sed -n '80,350p'; sed -n '1,180p' lib/decode.ts; sed -n '1,180p' lib/result.ts; git log --oneline --reverse -- lib/bind.ts lib/read.ts lib/param.ts

import type { Maybe } from "./maybe.ts"; import type { Param } from "./param.ts"; import type { CLIRead, Symbol } from "./read.ts"; import type { Rest } from "./rest.ts"; import type { Result } from "./result.ts"; import type { Word } from "./tokenize.ts"; import type { TokenInput, Tokenizer, TokenRange } from "./tokenizer.ts"; import type { AnyPhase, Issue, Path } from "./types.ts";

export interface Binding { readonly rest: Rest; readonly result: Result; }

export interface PhaseBinding { readonly rest: Rest; readonly model: Record<string, unknown>; readonly issues: Issue[]; readonly valid: boolean; }

export interface PhaseSegment { readonly range: TokenRange; readonly path: Path; }

export function fromCLI<const K extends string, T>(options: { readonly param: Param<K, T>; readonly view: TokenInput; readonly rest: Rest; }): Maybe<Binding> { let { param, view, rest } = options; return fromRead(param, param.cli.read(view), rest); }

export function fromValues<const K extends string, T>(options: { readonly param: Param<K, T>; readonly route: Path; readonly rest: Rest; }): Maybe<Binding> { let { param, route, rest } = options; let claim = rest.values.claim({ route, address: [param.name], });

if (!claim.result.exists) { return { exists: false }; }

return { exists: true, value: { rest: { ...rest, values: claim.rest, }, result: validate(param, claim.result.value.value, [param.name]), }, }; }

export function fromEnv<const K extends string, T>(options: { readonly param: Param<K, T>; readonly route: Path; readonly rest: Rest; }): Maybe<Binding> { let { param, route, rest } = options; let claim = rest.envs.claim({ route, address: [param.name], key: param.env, });

if (!claim.result.exists) { return { exists: false }; }

let value = claim.result.value.value;

return { exists: true, value: { rest: { ...rest, envs: claim.rest, }, result: decode(param, value, param.decode(value), [param.name]), }, }; }

export function bindPhase(options: { readonly phase: AnyPhase; readonly segment: PhaseSegment; readonly rest: Rest; }): PhaseBinding { let { phase, segment } = options; let rest = options.rest; let params = Object.values(phase.params) as Param<string, unknown>[]; let pending = new Map(params.map((param) => [param.name, param])); let results = new Map<string, Result>();

function settle( param: Param<string, unknown>, binding: Binding, ): void { rest = binding.rest; results.set(param.name, binding.result); pending.delete(param.name); }

function accept( param: Param<string, unknown>, attempt: Maybe<Binding>, ): void { if (!attempt.exists) { return; }

settle(param, attempt.value);

}

// Every pending reader proposes a claim against the same immutable view. // Commit the proposal beginning earliest in argv, then recompute the view. // This lets --port 9000 outrank a positional claim on 9000 without // attaching binding-policy metadata to either reader. while (true) { let horizon = first(rest.tokens, segment.range); let offer: { param: Param<string, unknown>; read: CLIRead; index: number; } | undefined;

for (let param of pending.values()) {
  let view = rest.tokens.view({
    range: segment.range,
    // Repeated options must see every occurrence in the phase, not only
    // the first option/value pair before the binding horizon.
    through: param.multiple ? undefined : horizon?.index,
  });
  let read = param.cli.read(view, param.multiple);

  if (read.result.ok && !read.result.value.exists) {
    continue;
  }

  let index = earliest(read.claim.tokens);
  if (!offer || index < offer.index) {
    offer = { param, read, index };
  }
}

if (!offer) {
  break;
}

accept(offer.param, fromRead(offer.param, offer.read, rest));

}

// Address sources have stable visibility once the route is known. They are // tried only after CLI has reached its fixed point so a provisional CLI miss // cannot let a lower-priority source settle the parameter too early. for (let source of [fromEnv, fromValues]) { for (let param of pending.values()) { accept( param, source({ param, route: segment.path, rest, }), ); } }

// Only total absence reaches the schema as undefined. This is where required, // optional, and defaulting parameters diverge. for (let param of pending.values()) { results.set(param.name, validate(param, undefined, [param.name])); }

let model: Record<string, unknown> = {}; let issues: Issue[] = []; let valid = true;

// Collect in declaration order, independent of the source or sweep that // settled each parameter. for (let param of params) { let result = results.get(param.name)!; issues.push(...result.issues ?? []);

if (result.ok) {
  model[param.name] = result.value;
} else {
  valid = false;
}

}

return { rest, model, issues, valid }; }

function fromRead( param: Param<string, T>, read: CLIRead, rest: Rest, ): Maybe<Binding> { let path = [param.name];

if (!read.result.ok) { return { exists: true, value: { rest: { ...rest, tokens: read.claim.rest, }, result: read.result, }, }; }

if (!read.result.value.exists) { return { exists: false }; }

let value = read.result.value.value; let result = Array.isArray(value) ? decodeMany(param, value, path) : decode( param, value, typeof value === "string" ? param.decode(value) : [value], path, ); result = merge(result, read.result.issues);

return { exists: true, value: { rest: { ...rest, tokens: read.claim.rest, }, result, }, }; }

function decodeMany( param: Param<string, T>, values: unknown[], path: string[], ): Result { let candidates: unknown[][] = [[]];

for (let value of values) { let decoded = typeof value === "string" ? param.decode(value) : [value]; candidates = candidates.flatMap((prefix) => decoded.map((candidate) => [...prefix, candidate]) ); }

return decode(param, values, candidates, path); }

function decode( param: Param<string, T>, value: unknown, candidates: unknown[], path: string[], ): Result { if (candidates.length === 0) { return { ok: false, issues: [{ message: unable to decode ${JSON.stringify(value)}, path, }], }; }

let issues: readonly Issue[] | undefined;

for (let candidate of candidates) { let result = validate(param, candidate, path); if (result.ok) { return result; } issues = issues ?? result.issues; }

return { ok: false, issues: issues ?? [], }; }

function validate( param: Param<string, T>, value: unknown, path: string[], ): Result { let validated = param.schema["~standard"].validate(value); if (validated instanceof Promise) { return { ok: false, issues: [{ message: async schemas are not allowed, path, }], }; }

if (validated.issues) { return { ok: false, issues: validated.issues.map((issue) => ({ ...issue, message: issue.message, path, })), }; }

return { ok: true, issues: [], value: validated.value, }; }

function merge( result: Result, issues: readonly Issue[] | undefined, ): Result { if (!issues || issues.length === 0) { return result; }

return { ...result, issues: [...issues, ...(result.issues ?? [])], }; }

function first(tokens: Tokenizer, range: TokenRange): Word | undefined { for (let token of tokens.view({ range })) { if (token.type === "word") { return token; } } }

function earliest(tokens: readonly Symbol[]): number { let first = Infinity;

for (let token of tokens) { first = Math.min(first, token.index); }

return { exists: true, value: { rest: { ...rest, envs: claim.rest, }, result: decode(param, value, param.decode(value), [param.name]), }, }; }

export function bindPhase(options: { readonly phase: AnyPhase; readonly segment: PhaseSegment; readonly rest: Rest; }): PhaseBinding { let { phase, segment } = options; let rest = options.rest; let params = Object.values(phase.params) as Param<string, unknown>[]; let pending = new Map(params.map((param) => [param.name, param])); let results = new Map<string, Result>(); let captures = new Map<string, Capture>();

function settle( param: Param<string, unknown>, binding: Binding, ): void { rest = binding.rest; results.set(param.name, binding.result); pending.delete(param.name); }

function accept( param: Param<string, unknown>, attempt: Maybe<Binding>, ): void { if (!attempt.exists) { return; }

settle(param, attempt.value);

}

// Every pending reader proposes a claim against the same immutable view. // Commit the proposal beginning earliest in argv, then recompute the view. // This lets --port 9000 outrank a positional claim on 9000 without // attaching binding-policy metadata to either reader. while (true) { let horizon = first(rest.tokens, segment.range); let offer: { param: Param<string, unknown>; read: CLIRead; index: number; } | undefined;

for (let param of pending.values()) {
  let view = rest.tokens.view({
    range: segment.range,
    through: horizon?.index,
  });
  let read = param.cli.read(view);

  if (read.result.ok && !read.result.value.exists) {
    continue;
  }

  let index = earliest(read.claim.tokens);
  if (!offer || index < offer.index) {
    offer = { param, read, index };
  }
}

if (!offer) {
  break;
}

if (
  offer.param.multiple && offer.read.result.ok &&
  offer.read.result.value.exists
) {
  rest = { ...rest, tokens: offer.read.claim.rest };
  let capture = captures.get(offer.param.name) ?? {
    values: [],
    issues: [],
  };
  captures.set(offer.param.name, {
    values: [
      ...capture.values,
      offer.read.result.value.value,
    ],
    issues: [
      ...capture.issues,
      ...(offer.read.result.issues ?? []),
    ],
  });
  continue;
}

accept(offer.param, fromRead(offer.param, offer.read, rest));

}

for (let param of pending.values()) { let capture = captures.get(param.name); if (!capture) { continue; }

results.set(
  param.name,
  merge(
    decodeMany(param, capture.values, [param.name]),
    capture.issues,
  ),
);
pending.delete(param.name);

}

// Address sources have stable visibility once the route is known. They are // tried only after CLI has reached its fixed point so a provisional CLI miss // cannot let a lower-priority source settle the parameter too early. for (let source of [fromEnv, fromValues]) { for (let param of pending.values()) { accept( param, source({ param, route: segment.path, rest, }), ); } }

// Only total absence reaches the schema as undefined. This is where required, // optional, and defaulting parameters diverge. for (let param of pending.values()) { results.set(param.name, validate(param, undefined, [param.name])); }

let model: Record<string, unknown> = {}; let issues: Issue[] = []; let valid = true;

// Collect in declaration order, independent of the source or sweep that // settled each parameter. for (let param of params) { let result = results.get(param.name)!; issues.push(...result.issues ?? []);

if (result.ok) {
  model[param.name] = result.value;
} else {
  valid = false;
}

}

return { rest, model, issues, valid }; }

type Capture = { values: unknown[]; issues: Issue[]; };

function fromRead( param: Param<string, T>, read: CLIRead, rest: Rest, ): Maybe<Binding> { let path = [param.name];

if (!read.result.ok) { return { exists: true, value: { rest: { ...rest, tokens: read.claim.rest, }, result: read.result, }, }; }

if (!read.result.value.exists) { return { exists: false }; }

let value = read.result.value.value; let candidates = typeof value === "string" ? param.decode(value) : [value]; let result = merge( decode(param, value, candidates, path), read.result.issues, );

return { exists: true, value: { rest: { ...rest, tokens: read.claim.rest, }, result, }, }; }

function decodeMany( param: Param<string, T>, values: unknown[], path: string[], ): Result { return decode(param, values, candidates(param, values), path); }

function* candidates( param: Param<string, T>, values: readonly unknown[], index = 0, prefix: readonly unknown[] = [], ): Iterable<unknown[]> { if (index === values.length) { yield [...prefix]; return; }

let value = values[index]; let decoded = typeof value === "string" ? param.decode(value) : [value]; for (let candidate of decoded) { yield* candidates(param, values, index + 1, [...prefix, candidate]); } }

function decode( param: Param<string, T>, value: unknown, candidates: Iterable, path: string[], ): Result { let issues: readonly Issue[] | undefined; let found = false;

for (let candidate of candidates) { found = true; let result = validate(param, candidate, path); if (result.ok) { return result; } issues = issues ?? result.issues; }

if (!found) { return { ok: false, issues: [{ message: unable to decode ${JSON.stringify(value)}, path, }], }; }

return { ok: false, issues: issues ?? [], }; }

function validate( param: Param<string, T>, export type Decoder = (value: string) => unknown[];

export const number: Decoder = (value) => { if (!numeric.test(value)) { return []; }

let decoded = Number(value); return Number.isFinite(decoded) ? [decoded] : []; };

export const scalar: Decoder = (value) => { return [...number(value), value]; };

export const boolean: Decoder = (value) => { if (value === "true") { return [true]; } if (value === "false") { return [false]; } return []; };

const numeric = /^-?\d+(?:.\d+)?(?:[eE][+-]?\d+)?$/; import type { Issue } from "./types.ts";

export type Result = { ok: true; value: T; issues?: readonly Issue[]; } | { ok: false; issues: readonly Issue[]; }; f4425e6 Make params composable 8115799 add models to routes 6bd4698 implement binding tests 2e1793b implement binding tests 80f8454 fix formatting and linting 78ef595 ✨ add reusable definition extensions b8b4ebb ✨ get basic dynamic routing working d5aedea ✨ Add tokenizer views over a shared token pool 6639daa ♻️ Thread parser sources through Rest f0b379f ✨ Implement source-aware phase binding 68c0540 ✨ Bind environment sources 21e1d0b 🧬 Fold route pipeline types without arity limits ed7514a ✨ Add positional arguments and fold parameter pipelines 459656f 🐛 Prevent arguments from stealing option values bf59fe7 ✨ Render positional arguments in help 9e79ea5 🏷️ Infer parameter outputs from schemas 8ce877d transform combinator dd069ef multiple on options 39e263a share matching logic and handle multiple decodes 73008c2 read multiple times to capture multiple()

▶ ✶ Thinking

Distinguishing Cartesian changes before PR32 and latest fix

Analyzing issue handling changes in readMany method

Confirming no issue loss in built-in reads

Planning custom CLI reader implementation

▶ ✓ Command sed -n '55,115p' test/parse.test.ts; sed -n '315,420p' test/parse.test.ts; sed -n '1,115p' lib/param.ts; sed -n '1,120p' lib/option.ts; git show 39e263a^:lib/bind.ts | rg -n "decodeMany|function decode" -A45 -B5 || true; git show 39e263a:lib/bind.ts | rg -n "decodeMany|function decode" -A55 -B5

sed -n '55,115p' test/parse.test.ts; sed -n '315,420p' test/parse.test.ts; sed -n '1,115p' lib/param.ts; sed -n '1,120p' lib/option.ts; git show 39e263a^:lib/bind.ts | rg -n "decodeMany|function decode" -A45 -B5 || true; git show 39e263a:lib/bind.ts | rg -n "decodeMany|function decode" -A55 -B5

name("simulacrum"), toggle(name("dryRun")), );

let fields = command( name("simulacrum"), option(name("port"), schema(type("number"))), );

let multipleOptions = command( name("simulacrum"), option(name("config"), multiple(), schema(type("string[]"))), option(name("port"), schema(type("number"))), );

let multipleNumbers = command( name("simulacrum"), option(name("port"), multiple(), schema(type("number[]"))), );

let multipleStrings = command( name("simulacrum"), option(name("config"), multiple(), schema(type("string[]"))), );

let optionalMultipleOptions = command( name("simulacrum"), option( name("config"), multiple(), schema(type("string[] | undefined")), ), );

let defaultedMultipleOptions = command( name("simulacrum"), option( name("config"), multiple(), schema(z.array(z.string()).default(["default.yml"])), ), );

let options = command( name("simulacrum"), option(name("dryRun"), schema(type("string | undefined"))), );

type TransformedModel = { port: number; secure: boolean };

let transformed = command( name("transformed"), checkpoint(), transform( (context) => ({ port: context.options.port, secure: context.options.port > 0, }), option(name("port"), schema(type("number"))), ), ); describe("parse()", () => { it("applies a transform after checkpoint resolution", () => { let step = parse(transformed, { argv: ["--port", "4100"] }); expectOk(step);

let result = step.resume({ ok: true, value: [] });
expectOk(result);
expect(result).toMatchObject({ model: { port: 4100, secure: true } });

});

it("collects repeated options in argv order", () => { let result = parse(multipleOptions, { argv: ["--config", "one", "--port", "4100", "--config=two"], });

expectOk(result);
expect(result).toMatchObject({
  model: { config: ["one", "two"], port: 4100 },
});

});

it("decodes each repeated option value before validating the array", () => { let result = parse(multipleNumbers, { argv: ["--port", "4100", "--port", "4101"], });

expectOk(result);
expect(result).toMatchObject({
  model: { port: [4100, 4101] },
});

});

it("reports an incomplete occurrence after valid repeated options", () => { let result = parse(multipleStrings, { argv: ["--config", "one", "--config"], });

expect(result).toMatchObject({
  ok: false,
  code: "unprocessable-content",
  route: "/",
  issues: [{ message: "--config requires a value" }],
});

});

it("preserves numeric-looking repeated strings", () => { let result = parse(multipleStrings, { argv: ["--config", "0012", "--config", "0034"], });

expectOk(result);
expect(result).toMatchObject({
  model: { config: ["0012", "0034"] },
});

});

it("lets an optional repeated option remain undefined", () => { let result = parse(optionalMultipleOptions, { argv: [] });

expectOk(result);
expect(result).toMatchObject({ model: { config: undefined } });

});

it("lets a defaulting schema supply an absent repeated option", () => { let result = parse(defaultedMultipleOptions, { argv: [] });

expectOk(result);
expect(result).toMatchObject({ model: { config: ["default.yml"] } });

});

it("collects repeated options from a custom singular reader", () => { let app = command( name("simulacrum"), option( name("plugin"), multiple(), customOption("--plugin"), schema(type("string[]")), ), ); let result = parse(app, { argv: ["--plugin", "one", "--plugin", "two"], });

expectOk(result);
expect(result).toMatchObject({ model: { plugin: ["one", "two"] } });

});

it("allows a transform to mutate the current model", () => { let result = parse(mutated, { argv: ["--port", "4100"] }); expectOk(result); expect(result).toMatchObject({ model: { port: 4100, secure: true } }); });

it("exposes active phase parameters to a transform", () => { let result = parse(inspected, { argv: ["--port", "4100"] }); expectOk(result); expect(result).toMatchObject({ model: { port: 4100 } }); });

it("applies a Standard Schema transform", () => { let result = parse(transformedBySchema, { argv: ["--port", "4100"] }); expectOk(result); expect(result).toMatchObject({ model: { port: 4100, secure: true }, }); import { type Decoder, scalar } from "./decode.ts"; import { type Check, type Fold, mark, type Transform, type TransformElement, type Unary, } from "./pipeline.ts"; import type { CLIBinding } from "./read.ts"; import type { Definition, OutputOf, Schema } from "./types.ts";

export interface Param<K extends string, T> extends Definition { schema: Schema; cli: CLIBinding; decode: Decoder; env?: string; multiple?: boolean; }

export interface MultipleParam<K extends string, T> extends Param<K, T> { multiple: true; }

export function param< const K extends string, const E extends readonly Unary[],

( start: Definition, ...elements: E & Check<Param<K, unknown>, E> ): Fold<Param<K, unknown>, E> { let zero: Param<K, unknown> = { ...start, schema: unknown, cli: { read(tokens) { let claim = tokens.claimAll(() => false); return { result: { ok: true, value: { exists: false }, issues: [], }, claim, }; }, }, decode: scalar, };

return elements.reduce( (value, element) => element(value as never), zero, ) as Fold<Param<K, unknown>, E>; }

export function schema( schema: S, ): TransformElement<SchemaTransform> { return mark<SchemaTransform>((param: Param<string, unknown>) => ({ ...param, schema, })); }

export interface SchemaTransform extends Transform { readonly input: Param<string, unknown>; readonly output: this["input"] extends MultipleParam<infer N, unknown> ? MultipleParam<N, OutputOf> : this["input"] extends Param<infer N, unknown> ? Param<N, OutputOf> : never; }

interface MultipleTransform extends Transform { readonly input: Param<string, unknown>; readonly output: this["input"] extends Param<infer N, infer T> ? MultipleParam<N, T> : never; }

export function multiple(): TransformElement { return mark((param: Param<string, unknown>) => ({ ...param, multiple: true, })); }

const unknown: Schema = { "~standard": { version: 1, vendor: "configliere", validate: (value) => ({ value }), }, }; import { type Param, param } from "./param.ts"; import { dasherize } from "./dasherize.ts"; import { brand, type Check, type Fold, type ParamElement, type Unary, } from "./pipeline.ts"; import { cli } from "./read.ts"; import type { AnyRoute, Definition } from "./types.ts";

export function option< const N extends string, const E extends readonly Unary[],

( named: Definition, ...elements: E & Check<Param<N, unknown>, E> & Valid ): ElementOf<N, Fold<Param<N, unknown>, E>> { const added = elements.reduce( (value, element) => element(value as never), param(named, cli([--${dasherize(named.name)}])), ) as Param<string, unknown>;

return brand<ElementOf<N, Fold<Param<N, unknown>, E>>>( (route: AnyRoute) => { let phases = [...route.phases]; let phase = phases.pop()!; phases.push({ ...phase, params: { ...phase.params, [added.name]: added, }, }); return { ...route, phases, }; }, ); }

type ValueOf

= P extends Param<string, infer T> ? T : never;

type Valid<E extends readonly Unary[]> = [ Fold<Param<string, unknown>, E>, ] extends [never] ? never : unknown;

type ElementOf<N extends string, P> = P extends Param<N, unknown> ? ParamElement<N, ValueOf

> : never; 242- result, 243- }, 244- }; 245-} 246- 247:function decode( 248- param: Param<string, T>, 249- value: unknown, 250- candidates: unknown[], 251- path: string[], 252-): Result { 253- if (candidates.length === 0) { 254- return { 255- ok: false, 256- issues: [{ 257- message: unable to decode ${JSON.stringify(value)}, 258- path, 259- }], 260- }; 261- } 262- 263- let issues: readonly Issue[] | undefined; 264- 265- for (let candidate of candidates) { 266- let result = validate(param, candidate, path); 267- if (result.ok) { 268- return result; 269- } 270- issues = issues ?? result.issues; 271- } 272- 273- return { 274- ok: false, 275- issues: issues ?? [], 276- }; 277-} 278- 279-function validate( 280- param: Param<string, T>, 281- value: unknown, 282- path: string[], 283-): Result { 284- let validated = param.schema["~standard"].validate(value); 285- if (validated instanceof Promise) { 286- return { 287- ok: false, 288- issues: [{ 289- message: async schemas are not allowed, 290- path, 291- }], 292- }; 224- if (!read.result.value.exists) { 225- return { exists: false }; 226- } 227- 228- let value = read.result.value.value; 229: let result = Array.isArray(value) ? decodeMany(param, value, path) : decode( 230- param, 231- value, 232- typeof value === "string" ? param.decode(value) : [value], 233- path, 234- ); 235- result = merge(result, read.result.issues); 236- 237- return { 238- exists: true, 239- value: { 240- rest: { 241- ...rest, 242- tokens: read.claim.rest, 243- }, 244- result, 245- }, 246- }; 247-} 248- 249:function decodeMany( 250- param: Param<string, T>, 251- values: unknown[], 252- path: string[], 253-): Result { 254- let candidates: unknown[][] = [[]]; 255- 256- for (let value of values) { 257- let decoded = typeof value === "string" ? param.decode(value) : [value]; 258- candidates = candidates.flatMap((prefix) => 259- decoded.map((candidate) => [...prefix, candidate]) 260- ); 261- } 262- 263- return decode(param, values, candidates, path); 264-} 265- 266:function decode( 267- param: Param<string, T>, 268- value: unknown, 269- candidates: unknown[], 270- path: string[], 271-): Result { 272- if (candidates.length === 0) { 273- return { 274- ok: false, 275- issues: [{ 276- message: unable to decode ${JSON.stringify(value)}, 277- path, 278- }], 279- }; 280- } 281- 282- let issues: readonly Issue[] | undefined; 283- 284- for (let candidate of candidates) { 285- let result = validate(param, candidate, path); 286- if (result.ok) { 287- return result; 288- } 289- issues = issues ?? result.issues; 290- } 291- 292- return { 293- ok: false, 294- issues: issues ?? [], 295- }; 296-} 297- 298-function validate( 299- param: Param<string, T>, 300- value: unknown, 301- path: string[], 302-): Result { 303- let validated = param.schema["~standard"].validate(value); 304- if (validated instanceof Promise) { 305- return { 306- ok: false, 307- issues: [{ 308- message: async schemas are not allowed, 309- path, 310- }], 311- }; 312- } 313- 314- if (validated.issues) { 315- return { 316- ok: false, 317- issues: validated.issues.map((issue) => ({ 318- ...issue, 319- message: issue.message, 320- path, 321- })),

▶ ✶ Thinking

Planning 2^20 validation benchmark

Testing exact validate call counting

▶ ✓ Command rg -n "function customOption|const customOption" test/parse.test.ts -A55; git show 397d572:lib/read.ts | sed -n '25,125p'; git show 73008c2:lib/read.ts | sed -n '25,125p'

rg -n "function customOption|const customOption" test/parse.test.ts -A55; git show 397d572:lib/read.ts | sed -n '25,125p'; git show 73008c2:lib/read.ts | sed -n '25,125p'

1058:function customOption( 1059- name: string, 1060-): IdentityElement<Param<string, unknown>> { 1061- const read: ReadCLI = (tokens) => { 1062- let claim = tokens.claimPair((flag, value) => 1063- flag.type === "flag" && flag.text === name && value.type === "word" 1064- ); 1065- let [, value] = claim.tokens; 1066- 1067- return value 1068- ? { 1069- claim, 1070- result: { 1071- ok: true, 1072- value: { exists: true, value: value.text }, 1073- issues: [], 1074- }, 1075- } 1076- : { 1077- claim, 1078- result: { 1079- ok: true, 1080- value: { exists: false }, 1081- issues: [], 1082- }, 1083- }; 1084- }; 1085- 1086- return brand<IdentityElement<Param<string, unknown>>>( 1087- (param: Param<string, unknown>) => ({ 1088- ...param, 1089- cli: { read }, 1090- }), 1091- ); 1092-} 1093- 1094-function expectOk<T extends { readonly ok: boolean }>( 1095- result: T, 1096-): asserts result is Extract<T, { readonly ok: true }> { 1097- expect(result.ok).toBe(true); 1098-} 1099- 1100-function expectUnprocessable<T extends { readonly ok: boolean }>( 1101- result: T, 1102-): asserts result is T & { 1103- readonly ok: false; 1104- readonly code: "unprocessable-content"; 1105- readonly issues: readonly unknown[]; 1106-} { 1107- expect(result).toMatchObject({ 1108- ok: false, 1109- code: "unprocessable-content", 1110- }); 1111-} result: Result<Maybe<string | boolean | unknown[]>>; claim: Claim; }

export interface CLIOptions { switch?: true; }

export function cli( names: readonly string[], options: CLIOptions = {}, ): IdentityElement<Param<string, unknown>> { const readOne: ReadCLI = (tokens) => { let [match] = matches(tokens); return match ? result(tokens, match) : nothing(tokens); };

const readMany: ReadCLI = (tokens) => { let found = matches(tokens); if (found.length === 0) { return nothing(tokens); }

let claimed = new Set(found.flatMap((match) => match.indices));
let issues = found.flatMap((match) =>
  "issue" in match ? [match.issue] : []
);
return {
  claim: tokens.claimAll((token) => claimed.has(token.index)),
  result: issues.length > 0 ? { ok: false, issues } : {
    ok: true,
    value: {
      exists: true,
      value: found.map((match) =>
        "value" in match ? match.value : undefined
      ),
    },
    issues: [],
  },
};

};

function matches(tokens: TokenInput): OptionMatch[] { let visible = Array.from(tokens); let found: OptionMatch[] = [];

for (let index = 0; index < visible.length; index++) {
  let token = visible[index];

  if (
    !options.switch && token.type === "setter" &&
    names.includes(`--${token.nameText}`)
  ) {
    found.push({ indices: [token.index], value: token.valueText });
    continue;
  }

  if (token.type !== "flag" || !names.includes(token.text)) {
    continue;
  }

  if (options.switch) {
    found.push({ indices: [token.index], value: true });
    continue;
  }

  let value = visible[index + 1];
  if (value?.type === "word" && value.index === token.index + 1) {
    found.push({
      indices: [token.index, value.index],
      value: value.text,
    });
    index++;
  } else {
    found.push({
      indices: [token.index],
      issue: { message: `${token.text} requires a value` },
    });
  }
}

return found;

}

const read: ReadCLI = (tokens, multiple = false) => { return multiple ? readMany(tokens) : readOne(tokens); };

return brand<IdentityElement<Param<string, unknown>>>( (param: Param<string, unknown>) => ({ ...param, cli: { read, syntax: { type: "option", label: options.switch ? names.join(", ") : ${names.join(", ")} <VALUE>, }, }, }), claim: Claim; }

export interface CLIOptions { switch?: true; }

export function cli( names: readonly string[], options: CLIOptions = {}, ): IdentityElement<Param<string, unknown>> { const read: ReadCLI = (tokens) => { if (options.switch) { let s = tokens.claimOne((t): t is Flag => { return t.type === "flag" && names.includes(t.text); }); let [flag] = s.tokens; return flag ? { result: { ok: true, value: { exists: true, value: true }, issues: [], }, claim: s, } : nothing(tokens); } let setter = tokens.claimOne((token): token is Setter => { return (token.type === "setter" && names.includes(--${token.nameText})); }); let [token] = setter.tokens; if (token) { return { claim: setter, result: { ok: true, value: { exists: true, value: token.valueText, }, issues: [], }, }; } let pair = tokens.claimPair((name, value) => { return name.type === "flag" && names.includes(name.text) && value.type === "word"; }); let [, value] = pair.tokens; if (value) { return { claim: pair, result: { ok: true, value: { exists: true, value: value.text, }, issues: [], }, }; } let bare = tokens.claimOne((t): t is Flag => { return t.type === "flag" && names.includes(t.text); }); let [incomplete] = bare.tokens; if (incomplete) { return { claim: bare, result: { ok: false, issues: [{ message: ${incomplete.text} requires a value, }], }, }; } return nothing(tokens); };

return brand<IdentityElement<Param<string, unknown>>>( (param: Param<string, unknown>) => ({ ...param, cli: { read, syntax: { type: "option", label: options.switch ? names.join(", ") : ${names.join(", ")} <VALUE>, }, }, }), ); }

function nothing(tokenizer: TokenInput): CLIRead { let claim = tokenizer.claimAll(() => false);

return {

▶ ✶ Thinking

Analyzing scalar order impact on validation

Evaluating schema validation inefficiencies

Fixing issue handling in readMany process

Revising diagnostic handling for scheduler edge

Analyzing readMany diagnostic behavior changes

  1. Cartesian decoder candidates

Why candidates exist

Configliere’s default scalar decoder deliberately offers more than one interpretation for numeric-looking text:

ts ⧉

scalar("0012") // [12, "0012"] scalar("hello") // ["hello"]

The schema chooses which interpretation is valid:

ts ⧉

schema(type("number")) // selects 12 schema(type("string")) // selects "0012"

For a single value, this is cheap: at most two schema validations.

What changed with multiple()

Before PR #32, Configliere decoded one captured value at a time. There was no Cartesian product.

The first implementation of multiple() introduced decodeMany(). Given:

text ⧉

--config 0012 --config 0034

each value has two candidates:

ts ⧉

[12, "0012"] [34, "0034"]

To find an array accepted by the aggregate schema, it tries every combination:

ts ⧉

[12, 34] [12, "0034"] ["0012", 34] ["0012", "0034"]

That is the Cartesian product.

This behavior was introduced in commit 39e263a, before the latest fix. The implementation eagerly built the complete array of combinations:

ts ⧉

candidates = candidates.flatMap((prefix) => decoded.map((candidate) => [...prefix, candidate]) );

What the latest fix changed

The latest commit did not introduce the Cartesian search. It changed it from an eagerly allocated array into a lazy generator:

ts ⧉

function* candidates(...) { // yield combinations one at a time }

That is a real improvement:

• Before: construct all combinations in memory before validation. • Now: construct and validate one combination at a time. • Validation stops as soon as one candidate succeeds.

So the latest fix substantially improves memory behavior and successful early matches. It does not change the worst-case number of schema validations.

A concrete degenerative case

Ironically, the existing “preserves numeric-looking repeated strings” behavior provides the clearest example:

ts ⧉

option( name("config"), multiple(), schema(type("string[]")), )

With:

text ⧉

--config 001 --config 002 --config 003

The decoder prefers the numeric candidate first. The combinations are tried approximately in this order:

ts ⧉

[1, 2, 3] [1, 2, "003"] [1, "002", 3] [1, "002", "003"] ["001", 2, 3] ["001", 2, "003"] ["001", "002", 3] ["001", "002", "003"] // finally succeeds

For k numeric-looking strings, the desired all-string combination is last:

│ Repetitions │ Schema validations │ ├─────────────┼────────────────────┤ │ 2 │ 4 │ │ 10 │ 1,024 │ │ 20 │ 1,048,576 │ │ 30 │ 1,073,741,824 │

This isn’t limited to invalid schemas. A perfectly valid string[] schema can hit the worst case because string is the decoder’s second candidate.

By contrast:

ts ⧉

schema(type("number[]"))

accepts the first, all-numeric combination and performs only one validation.

Why it is difficult to avoid under the current design

The schema validates the entire collected array, not each individual element. Standard Schema is opaque; Configliere cannot ask it “what is your element type?”

Therefore, it cannot independently decide whether "001" should become 1 or remain "001" without trying aggregate combinations.

Possible design directions include:

• Accept the behavior but impose a candidate-attempt limit. • Require an explicit decoder where ambiguity matters. • Make multiple() lift an element schema, validating each captured value independently. • Separate per-element validation from aggregate array validation. • Change decoder selection so it does not use schema probing to disambiguate.

That last point touches a broader paradigm question: we currently say schemas validate interpreted values, but this algorithm effectively uses the schema to select among interpretations.

My revised assessment: the latest commit improves an existing PR #32 problem; it doesn’t create it. But the exponential case is concrete enough—especially for string[]—that I would address or explicitly constrain it.


  1. Dropped successful-read diagnostics

How the latest implementation works

Successful repeated reads are accumulated separately:

ts ⧉

captures.set(param.name, { values: [...capture.values, value], issues: [...capture.issues, ...read.result.issues], });

After all occurrences are collected, those issues are merged into the final decoding result:

ts ⧉

merge( decodeMany(param, capture.values, [param.name]), capture.issues, )

That works when every occurrence succeeds.

But a failed occurrence takes another path:

ts ⧉

accept(offer.param, fromRead(offer.param, offer.read, rest));

accept() immediately settles the parameter and removes it from pending. The later capture-finalization loop only visits parameters that remain pending:

ts ⧉

for (let param of pending.values()) { let capture = captures.get(param.name); // ... }

Therefore, previously accumulated issues are never merged after a later failure.

Concrete example

Imagine a custom reader with these results:

text ⧉

--plugin old-style → success("old-style") warning: "old-style syntax is deprecated"

--plugin → failure: "--plugin requires a value"

The sequence is:

  1. Capture "old-style".
  2. Store its deprecation warning in captures.
  3. Encounter the incomplete second option.
  4. Settle immediately with the missing-value failure.
  5. Remove the parameter from pending.
  6. Never merge the stored deprecation warning.

The final result contains only:

ts ⧉

[{ message: "--plugin requires a value" }]

The earlier warning disappears.

Was it like this before?

There are three relevant periods.

Before PR #32

There was no repeated collection, so this exact situation did not exist.

A single successful read’s issues were merged normally:

ts ⧉

merge(decode(...), read.result.issues)

Earlier PR #32 implementation

The built-in reader collected all occurrences in one call. If any occurrence was malformed, it returned a failed result containing the malformed-occurrence issues and discarded the successful values.

So for built-in options, successful diagnostics would also effectively disappear—but built-in successful CLI reads always produce issues: []. There was nothing observable to lose.

Custom singular readers did not work correctly with repetition at that point: the first successful occurrence settled the parameter, leaving later occurrences unprocessed.

Latest implementation

The latest change enables repeated custom singular readers and introduces an explicit accumulation buffer. That makes the diagnostic-loss edge structurally visible:

• All successful occurrences: their issues are preserved. • A later failed occurrence: earlier successful issues are dropped.

So this is not a regression in ordinary built-in behavior. It is an edge introduced by the new, more general custom-reader collection path.

How serious is it?

Less serious than my initial wording may have suggested.

Today:

• Built-in successful option reads always have empty issue arrays. • The existing tests do not demonstrate a real built-in warning being lost. • It matters only for custom readers—or future built-in readers—that attach nonfatal diagnostics to successful captures.

It is still easy to make the invariant cleaner: when a repeated occurrence fails, merge any accumulated capture issues into the failure before settling it.

There is a related behavioral change: the old bulk reader could report several malformed repeated occurrences at once. The new scheduler settles on the first failed occurrence, so later occurrences are no longer read by that parameter and may instead become unexpected-input diagnostics. That policy should probably be intentional and tested.

Bottom line

• The Cartesian explosion was introduced by PR #32’s aggregate decoding, not by its latest fix. The latest fix improves memory use but retains exponential worst-case time. • The dropped-diagnostics edge is newly exposed by the latest per-occurrence scheduler, but it does not currently affect built-in readers because successful built-in reads have no diagnostics. It is primarily a custom-reader/future-proofing concern.

Codex> can we simplify it by saying that every option that matches must be decoded by the same decoder? In other words, the candidates are not

[1, 2, 3] [1, 2, "003"] [1, "002", 3] [1, "002", "003"] ["001", 2, 3] ["001", 2, "003"] ["001", "002", 3] ["001", "002", "003"] // finally succeeds

But are instead

[1,2, 3] ["001", "002", "003"] ▶ Thought

▶ ✶ Thinking

Redesigning decoder alignment strategy

Considering decoder type refactoring

Yes. That is a much cleaner rule:

For a repeated parameter, choose one decoder interpretation and apply it consistently to every captured representation.

With the default scalar decoder understood as two alternative strategies:

ts ⧉

number text

this input:

text ⧉

--value 001 --value 002 --value 003

would produce only:

ts ⧉

[1, 2, 3] // number strategy ["001", "002", "003"] // text strategy

That changes candidate growth from exponential to linear in the number of decoder strategies:

• Current: (d^k), where d is candidates per value and k is repetitions. • Proposed: at most (d) aggregate candidates.

It also has a good semantic justification. A parameter declaration describes one kind of value. Repetition changes cardinality, not interpretation. A repeated option should not silently become a heterogeneous array merely because individual tokens were ambiguous.

There is one implementation wrinkle: the current decoder abstraction loses decoder identity:

ts ⧉

type Decoder = (value: string) => unknown[];

Once it returns [12, "0012"], we know there are two candidates, but not that one came from the number decoder and one from the text decoder. Candidate positions also cannot safely stand in for decoder identity:

ts ⧉

scalar("hello") // ["hello"] scalar("0012") // [12, "0012"]

Zipping by array position would incorrectly produce:

ts ⧉

["hello", 12]

even though those came from different strategies.

The decoder abstraction should preserve its alternatives, conceptually:

ts ⧉

type Decode = (value: string) => Maybe;

type Decoder = readonly Decode[];

Then aggregate decoding becomes:

ts ⧉

function* decodeMany(decoder: Decoder, values: string[]) { for (let decode of decoder) { let result = values.map(decode);

if (result.every((item) => item.exists)) {
  yield result.map((item) => item.value);
}

} }

For mixed text:

text ⧉

--value hello --value 003

the number strategy fails because "hello" is not numeric. The text strategy succeeds:

ts ⧉

["hello", "003"]

No mixed ["hello", 3] candidate is offered.

I think this fits the paradigm better:

• The decoder interprets source representations. • One parameter uses one interpretation consistently. • The schema validates the resulting aggregate value. • multiple() changes capture multiplicity without inventing per-element interpretation combinations.

I would promote this to an explicit decoder invariant, because it affects custom decoders too:

When binding multiple representations for one parameter, every representation is interpreted by the same decoder alternative.

That removes the exponential search and gives repeated options predictable homogeneity.

Codex> right, so it's almost as though the multiple is a decoder that replaces all the other decoders? basically, it is the one that sees all options, and it all candidates are arrays, one for each decoder. So it's like the scalar decoders which return an array of candidates, but this is just a decoder that returns an array of candidates, where each candidate is an array.

Still, it means that all options with that name must be seen before we can begin trying candidates ▶ Thought

▶ ✶ Thinking

Designing batch decoder strategy

Clarifying decoder batch processing and precedence

Defining dynamic horizon for parameter phases

Exactly. I’d describe multiple() as lifting ordinary decoding into batch decoding.

If an ordinary decoder is conceptually:

ts ⧉

decode: Representation → Candidate[]

then the multiple form is:

ts ⧉

decodeMany: Representation[] → Candidate[][]

Each output candidate is one complete interpretation of the captured collection.

More specifically, if scalar decoding is a choice between strategies:

ts ⧉

scalar = number | text

then lifting it gives:

ts ⧉

multiple(scalar) = map(number) | map(text)

So:

ts ⧉

["001", "002", "003"]

produces:

ts ⧉

[ [1, 2, 3], ["001", "002", "003"], ]

It does not produce sequence(map(scalar)), which is what creates every mixed permutation.

I would say it replaces the parameter’s scalar decoding operation at the binding boundary, while preserving the underlying decoder alternatives. It is less “run the scalar decoder independently on every item” and more:

Run each decoding strategy against the whole capture set.

And yes, that establishes a necessary two-stage process:

text ⧉

capture every safely visible occurrence ↓ obtain the complete representation array ↓ generate consistently decoded array candidates ↓ ask the schema to validate candidates

We cannot begin schema-based candidate selection after the first occurrence because we do not yet know the value that will ultimately be validated. We can incrementally capture raw representations, but decoding and validation must wait until CLI collection reaches its fixed point.

The scope of “every occurrence” is important: it means every occurrence safely visible to this parameter in its current route segment and phase. It must not mean scanning through unresolved route selectors or into child segments.

This also clarifies responsibility nicely:

• The CLI reader captures one occurrence. • bindPhase() repeatedly invokes it while the horizon advances. • The multiple decoder receives the complete captured representation array. • Each underlying decoder strategy either interprets the entire array or fails. • The schema validates each resulting array candidate. • The first valid candidate wins.

The decoder representation probably needs to change to preserve strategy identity. The existing:

ts ⧉

type Decoder = (value: string) => unknown[];

only exposes flattened candidates. It cannot tell whether candidate zero for one value came from the same strategy as candidate zero for another.

Something closer to this would express the semantics:

ts ⧉

type Decode = (value: string) => Maybe;

type Decoder = readonly Decode[];

Then:

ts ⧉

function* multiple( decoder: Decoder, values: readonly string[], ): Iterable<readonly unknown[]> { for (let decode of decoder) { let results = values.map(decode);

if (results.every(present)) {
  yield results.map(result => result.value);
}

} }

Or the public abstraction could be generalized directly around batches:

ts ⧉

interface Decoder<Input, Candidate> { decode(input: Input): Iterable; }

with:

ts ⧉

Decoder<string, unknown> // singular Decoder<readonly string[], unknown[]> // multiple

The essential invariant is:

Repetition delays decoding until capture is complete, and every candidate uses one consistent decoding strategy across the entire captured collection.

That feels much more principled than merely limiting the Cartesian search. It defines what repeated decoding means.

Codex> Preserving decoder identity is no doubt a good thing, but I want to explore if it necessary if we say that multiple() is in fact a decoder itself and that the array of candidates it presents is still the same length as the number of decoders there are. if I have string and number decoders, then when I say --count 1, I get [1, "1"] if it is multiple, then the candidates I get are [[1], ["1"]]

I other words, when a parameter is multiple(), it replaces the multiplicity of decoders it might have with a single decoder comprised of all of them.

I am not fully confident of this, but I wat to find the simplest mechanism.

In either case, it means that we cannot bind route parameters until the route is "closed" and all representations have been collected. ▶ Thought

▶ ✶ Thinking

Designing composite decoder structure

Planning decoder composition and binding sequence

Clarifying dynamic route binding boundaries

Clarifying route selector resolution timing

Designing decoder composition strategy

I think that works, and “decoder identity” may be stronger language than necessary.

What must be preserved is not an identity value or tag. We only need to preserve the constituent decoder boundaries long enough to construct the multiple decoder.

Conceptually:

ts ⧉

const scalar = oneOf(number, string); const repeated = multiple(scalar);

For one representation:

ts ⧉

scalar("1") // [1, "1"]

For several representations:

ts ⧉

repeated(["1", "2"]) // [[1, 2], ["1", "2"]]

repeated is one decoder. It closes over the constituent scalar decoders and applies each one to the entire representation set.

ts ⧉

function multiple( decoders: readonly ScalarDecoder[], ): MultipleDecoder { return (values) => { let candidates: T[][] = [];

for (let decode of decoders) {
  let candidate: T[] = [];

  for (let value of values) {
    let decoded = decode(value);

    if (!decoded.exists) {
      candidate = [];
      break;
    }

    candidate.push(decoded.value);
  }

  if (candidate.length === values.length) {
    candidates.push(candidate);
  }
}

return candidates;

}; }

Thus the number of candidates is at most the number of constituent decoders:

text ⧉

d decoders × k representations → at most d candidates

Each candidate contains k values.

The important qualification is that we cannot construct this correctly from only the flattened function:

ts ⧉

(value: string) => unknown[]

Given:

ts ⧉

decode("abc") // ["abc"] decode("123") // [123, "123"]

the flattened results do not tell multiple() which alternatives correspond. But this doesn’t require globally identifiable or branded decoders. It only requires the parameter to retain its ordered collection of constituent decoding functions:

ts ⧉

type ScalarDecoder = (value: string) => Maybe;

interface Decoding { readonly alternatives: readonly ScalarDecoder[]; }

At binding time:

ts ⧉

param.multiple ? decodeMany(param.decoders, representations) : decodeOne(param.decoders, representation);

Or multiple() can replace that with a single already-lifted function:

ts ⧉

interface Param { decode: ScalarDecoder | MultipleDecoder; }

I slightly prefer retaining the alternatives and lifting at the boundary. It keeps multiple() as a cardinality policy and makes it possible for multiple() to appear before or after decoder combinators in the definition pipeline. But semantically, your description is right: the resulting multiple decoder is a single decoder composed from all scalar alternatives.

One subtlety: “same length as the number of decoders” is really “at most that length.” A decoder alternative may fail for at least one representation:

ts ⧉

values = ["hello", "123"]

number across all values → fails string across all values → ["hello", "123"]

So the multiple decoder yields only:

ts ⧉

[["hello", "123"]]

When binding can happen

I agree with the underlying conclusion, with a scope qualification:

A multiple parameter cannot be decoded, validated, or settled until its current binding scope is closed and every safely visible representation has been collected.

That does not necessarily require the final application route to be completely known.

For example, in a dynamic phase:

text ⧉

--config one --config two auth0 --port 9000 ^ unresolved horizon

The current phase can:

  1. Capture both config representations.
  2. Stop when it reaches auth0.
  3. Determine that no current parameter can claim auth0.
  4. Close the current phase’s CLI binding scope.
  5. Decode and validate ["one", "two"].
  6. Expose the increment.
  7. Potentially discover auth0 after resumption.

It must not search beyond auth0 looking for more --config occurrences, because those could belong to a future child route.

So I’d formulate the lifecycle as:

text ⧉

route search with currently known graph ↓ incrementally capture representations within the safe segment ↓ CLI fixed point closes the phase’s binding scope ↓ decode complete representation sets ↓ validate and settle parameters ↓ fall through to Env / Values only for absent CLI parameters

Internally, singular parameters may be decoded sooner as an optimization. Semantically, however, all phase results become observable only after that phase’s binding scope reaches a fixed point.

This gives us a simple invariant:

Capture is incremental; interpretation is finalization.

For singular parameters finalization has one representation. For multiple parameters it has the complete representation collection.

Codex> I think I see what you're saying. It's not so much identity, but we need to at least retain the idea that there was a decoder, so instead of

decode("abc") // ["abc"] decode("123") // [123, "123"]

it would be

decode("abc") // [Nothing, Just("abc")] decode("123") // [Just(123), Just("123")]

now that the value(i) corresponds to decoder(i) Exactly. The essential property is positional correspondence:

Candidate slot i always represents decoder alternative i.

So instead of dropping failed alternatives:

ts ⧉

decode("abc") // ["abc"] decode("123") // [123, "123"]

we preserve their slots:

ts ⧉

decode("abc") // [Nothing, Just("abc")]

decode("123") // [Just(123), Just("123")]

Now lifting to multiple values is a transpose followed by filtering incomplete rows:

ts ⧉

[ [Nothing, Just("abc")], [Just(123), Just("123")], ]

Transpose by decoder slot:

ts ⧉

number: [Nothing, Just(123)] string: [Just("abc"), Just("123")]

Discard any decoder row containing Nothing:

ts ⧉

[ ["abc", "123"], ]

For entirely numeric input:

ts ⧉

decode("001") // [Just(1), Just("001")] decode("002") // [Just(2), Just("002")]

the transpose produces:

ts ⧉

[ [1, 2], ["001", "002"], ]

This permits a particularly small change to the current abstraction:

ts ⧉

type Candidate = | { readonly exists: false } | { readonly exists: true; readonly value: T };

type Decoder = (value: string) => readonly Candidate[];

Then singular decoding removes Nothing and validates each remaining value:

ts ⧉

function one(decoder: Decoder, value: string): unknown[] { return decoder(value).flatMap((candidate) => candidate.exists ? [candidate.value] : [] ); }

Multiple decoding preserves the positions and builds at most one array per decoder slot:

ts ⧉

function many( decoder: Decoder, values: readonly string[], ): unknown[][] { let rows = values.map(decoder); let width = Math.max(0, ...rows.map((row) => row.length)); let candidates: unknown[][] = [];

for (let index = 0; index < width; index++) { let column = rows.map((row) => row[index]);

if (
  column.every(
    (candidate): candidate is Extract<
      Candidate<unknown>,
      { exists: true }
    > => candidate?.exists === true,
  )
) {
  candidates.push(column.map((candidate) => candidate.value));
}

}

return candidates; }

There is one invariant we would need to enforce:

Every invocation of a decoder returns the same number of candidate slots in the same order.

That means a composite decoder such as scalar must preserve failed slots:

ts ⧉

const scalar: Decoder = (value) => [ numeric(value) ? { exists: true, value: Number(value) } : { exists: false }, { exists: true, value }, ];

I like this representation. It avoids introducing public decoder IDs or turning the decoder into a collection of separately managed objects. The alternatives remain encapsulated in one function, while their positional structure is preserved well enough for multiple() to lift them consistently.

The model is then:

text ⧉

singular: compact successful slots into scalar candidates multiple: transpose slots, discard incomplete columns, produce array candidates

That seems like the simplest mechanism consistent with the semantics we want.

Codex> but if you discard, then you cannot represent multiple as a decoder. Instead of

[ ["abc", "123"], ]

This would be the decoded multiple

[ Nothing, Just(["abc", "123"]), ] Yes—you’re right. If multiple() is itself a decoder, it must preserve the same candidate-slot structure. It should not compact successful candidates.

Given:

ts ⧉

decode("abc") // [Nothing, Just("abc")]

decode("123") // [Just(123), Just("123")]

multiple(decode) transposes by slot and sequences each column:

ts ⧉

multiple(decode)(["abc", "123"]) // [ // Nothing, // Just(["abc", "123"]), // ]

For two numeric representations:

ts ⧉

multiple(decode)(["001", "002"]) // [ // Just([1, 2]), // Just(["001", "002"]), // ]

So the abstraction remains closed:

ts ⧉

type Maybe = | { readonly exists: false } | { readonly exists: true; readonly value: T };

type Decoder<Input, Output> = ( input: Input, ) => readonly Maybe[];

And multiple() is a decoder transformation:

ts ⧉

function multiple<Input, Output>( decode: Decoder<Input, Output>, ): Decoder<readonly Input[], readonly Output[]> { return (inputs) => { let rows = inputs.map(decode); let width = rows[0]?.length ?? 0;

return Array.from({ length: width }, (_, index) => {
  let column = rows.map((row) => row[index]);

  if (
    column.every(
      (candidate): candidate is Extract<
        Maybe<Output>,
        { exists: true }
      > => candidate?.exists === true,
    )
  ) {
    return {
      exists: true,
      value: column.map((candidate) => candidate.value),
    };
  }

  return { exists: false };
});

}; }

The algebra is:

text ⧉

Decoder<A, B> ↓ multiple Decoder<A[], B[]>

Or more concretely:

text ⧉

A → Maybe[] ↓ A[] → Maybe<B[]>[]

Each candidate slot remains aligned before and after lifting:

text ⧉

slot 0: number decoder slot 1: string decoder

If any input fails slot 0, the complete lifted slot 0 is Nothing. Slot 1 is independent.

Then schema selection is consistently downstream:

ts ⧉

for (let candidate of decode(representations)) { if (candidate.exists) { let result = validate(candidate.value);

if (result.ok) {
  return result;
}

} }

No compaction is needed, and no decoder identity is exposed. Positional structure is preserved through arbitrary decoder transformations.

This is indeed simpler and more compositional: multiple() does not become special binding logic. It is an ordinary higher-order decoder that changes both the decoder’s input cardinality and candidate output type while retaining candidate alignment.

Codex> ok, now that we have a total design, what would need to change. ▶ Thought

▶ ✶ Thinking

Planning API and binding updates

Designing generalized candidate slots

I’ll map the design onto the current PR #32 code so the change list is concrete: decoder types and built-ins, multiple()’s role, phase finalization, source precedence, and the regression tests that should define it.

▶ Thought, ran a command

▶ ✓ Command "rg -n "Decoder|decode(|decoder|scalar|boolean|number|multiple(" lib test README.md mod.ts | head -240; sed -n '1,150p' lib/decode.ts; sed -n '1,130p' lib/argument.ts; sed -n '1,130p' lib/toggle.ts; sed -n '1,120p' lib/maybe.ts; sed -n '1,120p' lib/read.ts"

/bin/zsh -lc "rg -n \"Decoder|decode\\(|decoder|scalar|boolean|number|multiple\\(\" lib test README.md mod.ts | head -240; sed -n '1,150p' lib/decode.ts; sed -n '1,130p' lib/argument.ts; sed -n '1,130p' lib/toggle.ts; sed -n '1,120p' lib/maybe.ts; sed -n '1,120p' lib/read.ts"

README.md:62: option(name("port"), schema(z.number().default(4000))), README.md:123: result.model.verbose; // boolean README.md:127: result.model.port; // number README.md:128: result.models["/"].verbose; // boolean README.md:132: result.model.dryRun; // boolean README.md:133: result.models["/"]; // { verbose: boolean } README.md:191:Use multiple() when an option may be supplied more than once. The option's README.md:199: multiple(), README.md:219: option(name("port"), description("server port"), schema(z.number())), README.md:322: option(name("port"), schema(z.number())), README.md:352: result.model; // { config: string; port: number } lib/toggle.ts:1:import { boolean as decode } from "./decode.ts"; lib/toggle.ts:20: ...elements: E & Check<Param<N, boolean>, E> lib/toggle.ts:21:): ElementOf<N, Fold<Param<N, boolean>, E>> { lib/toggle.ts:30: return brand<ElementOf<N, Fold<Param<N, boolean>, E>>>( lib/toggle.ts:107:const bool: Schema = { lib/toggle.ts:114: : typeof value === "boolean" lib/toggle.ts:116: : { issues: [{ message: "expected boolean" }] }; test/print.test.ts:63: option(name("port"), schema(type("number"))), test/print.test.ts:196: port: must be a number (was undefined), test/dynamic.test.ts:40: { port: number }, test/dynamic.test.ts:42: readonly [Done<{ port: number }, []>] test/dynamic.test.ts:64: { a: string; b: number }, test/dynamic.test.ts:68: Done<{ b: number }, []>, test/dynamic.test.ts:74: { a: string; b: number; c: boolean; d: string }, test/dynamic.test.ts:77: Next<{ c: boolean }, [], Plugins>, test/dynamic.test.ts:84: Next<{ b: number }, [], Services>, test/dynamic.test.ts:85: Next<{ c: boolean }, [], Plugins>, test/dynamic.test.ts:129: option(name("port"), schema(type("number"))), test/dynamic.test.ts:140: { config: string; port: number; domain: string } test/dynamic.test.ts:146: readonly [Done<{ port: number; domain: string }, []>] test/dynamic.test.ts:158: option(name("port"), schema(type("number"))), test/dynamic.test.ts:169: Equal<ModelOf, { config: string; port: number }> test/dynamic.test.ts:177: option(name("port"), schema(type("number"))), test/dynamic.test.ts:180: expectType<Equal<ModelOf, { port: number }>>(true); test/dynamic.test.ts:186: Done<{ port: number }, []>, test/dynamic.test.ts:237: extend(option(name("port"), schema(type("number")))) test/dynamic.test.ts:358: option(name("config"), multiple(), schema(type("string[]"))), test/dynamic.test.ts:362: option(name("config"), multiple(), schema(type("string[]"))), test/dynamic.test.ts:807: readonly count: number; test/tokenizer.test.ts:72: let tested: number[] = []; test/tokenizer.test.ts:187:function indices(tokens: Iterable): number[] { test/bind.test.ts:18: let port = param(name("port"), schema(z.number())); test/bind.test.ts:32: let port = param(name("port"), schema(z.number().optional())); test/bind.test.ts:47: let port = param(name("port"), schema(z.number().default(9000))); test/bind.test.ts:64: param: param(name("port"), schema(z.number())), test/bind.test.ts:107: schema(z.number()), test/bind.test.ts:126: schema(z.number()), test/bind.test.ts:174: schema(z.number()), test/bind.test.ts:239: schema(z.number()), lib/tokenizer.ts:7: claimOne(match: (token: T) => boolean): Claim<T, T>; lib/tokenizer.ts:9: claimPair(match: (a: T, b: T) => boolean): Claim<T, T>; lib/tokenizer.ts:12: claimAll(match: (token: T) => boolean): Claim<T, T>; lib/tokenizer.ts:16: readonly start: number; lib/tokenizer.ts:17: readonly end?: number; lib/tokenizer.ts:22: readonly through?: number; lib/tokenizer.ts:27: readonly claimed: ReadonlySet; lib/tokenizer.ts:31: claimed: ReadonlySet = new Set(), lib/tokenizer.ts:42: claimOne(match: (token: T) => boolean): Claim<T, T>; lib/tokenizer.ts:43: claimOne(match: (token: T) => boolean): Claim { lib/tokenizer.ts:55: claimPair(match: (a: T, b: T) => boolean): Claim<T, T> { lib/tokenizer.ts:74: claimAll(match: (token: T) => boolean): Claim<T, T>; lib/tokenizer.ts:75: claimAll(match: (token: T) => boolean): Claim { lib/tokenizer.ts:77: let claimed = new Set(); lib/tokenizer.ts:119: claimOne(match: (token: T) => boolean): Claim<T, T>; lib/tokenizer.ts:120: claimOne(match: (token: T) => boolean): Claim { lib/tokenizer.ts:132: claimPair(match: (a: T, b: T) => boolean): Claim<T, T> { lib/tokenizer.ts:151: claimAll(match: (token: T) => boolean): Claim<T, T>; lib/tokenizer.ts:152: claimAll(match: (token: T) => boolean): Claim { lib/tokenizer.ts:154: let claimed = new Set(); lib/tokenizer.ts:185: claimed: Iterable, test/value.test.ts:19: option(name("port"), schema(type("number"))), test/value.test.ts:55: option(name("delay"), schema(type("number"))), test/value.test.ts:56: option(name("port"), schema(type("number"))), test/value.test.ts:60: option(name("delay"), schema(type("number"))), test/value.test.ts:100: option(name("port"), schema(type("number"))), test/value.test.ts:128: it("validates values without applying the CLI decoder", () => { test/value.test.ts:131: option(name("port"), schema(type("number"))), test/value.test.ts:153: option(name("port"), schema(type("number"))), test/value.test.ts:159: value: { port: "not a number" }, test/value.test.ts:175: option(name("port"), schema(type("number"))), test/value.test.ts:200: option(name("port"), schema(type("number"))), test/value.test.ts:221: option(name("port"), schema(type("number"))), test/value.test.ts:242: option(name("port"), schema(type("number"))), test/value.test.ts:269: option(name("port"), schema(type("number | undefined"))), test/value.test.ts:300: option(name("port"), schema(type("number"))), test/value.test.ts:319: option(name("port"), schema(type("number"))), test/value.test.ts:342: option(name("port"), schema(type("number"))), test/value.test.ts:346: option(name("port"), schema(type("number"))), test/value.test.ts:466: option(name("port"), schema(type("number"))), lib/bind.ts:19: readonly valid: boolean; lib/bind.ts:88: result: decode(param, value, param.decode(value), [param.name]), lib/bind.ts:134: index: number; lib/bind.ts:271: let candidates = typeof value === "string" ? param.decode(value) : [value]; lib/bind.ts:273: decode(param, value, candidates, path), lib/bind.ts:294: return decode(param, values, candidates(param, values), path); lib/bind.ts:309: let decoded = typeof value === "string" ? param.decode(value) : [value]; lib/bind.ts:405:function earliest(tokens: readonly Symbol[]): number { lib/param.ts:1:import { type Decoder, scalar } from "./decode.ts"; lib/param.ts:16: decode: Decoder; lib/param.ts:18: multiple?: boolean; lib/param.ts:48: decode: scalar, lib/param.ts:81:export function multiple(): TransformElement { lib/types.ts:118: R["methods"][number], lib/types.ts:160:export type MethodsOf = R["methods"][number]; test/env.test.ts:21: option(name("port"), schema(z.number())), test/env.test.ts:87: schema(z.number()), test/env.test.ts:111: schema(z.number()), test/env.test.ts:134: schema(z.number()), test/env.test.ts:138: expectType<Equal<ModelOf, { port: number }>>(true); test/env.test.ts:143: it("decodes environment text with the parameter decoder", () => { test/env.test.ts:146: option(name("port"), schema(z.number())), test/env.test.ts:160: it("tries later decoder candidates", () => { test/env.test.ts:180: option(name("port"), schema(z.number())), test/env.test.ts:234: option(name("port"), schema(z.number())), test/env.test.ts:252: option(name("port"), schema(z.number().default(9000))), test/env.test.ts:269: option(name("port"), schema(z.number())), test/env.test.ts:293: multiple(), test/env.test.ts:319: multiple(), test/env.test.ts:343: option(name("port"), schema(z.number())), test/env.test.ts:361: option(name("port"), schema(z.number())), test/env.test.ts:380: option(name("port"), schema(z.number())), test/env.test.ts:399: option(name("port"), schema(z.number())), test/env.test.ts:417: option(name("port"), schema(z.number())), test/env.test.ts:437: option(name("port"), schema(z.number())), test/env.test.ts:457: option(name("port"), schema(z.number())), test/env.test.ts:478: option(name("port"), schema(z.number().default(9000))), test/env.test.ts:502: option(name("port"), schema(z.number())), test/env.test.ts:521: option(name("port"), schema(z.number())), test/env.test.ts:548: option(name("port"), schema(z.number())), test/env.test.ts:574: schema(z.number()), test/env.test.ts:582: schema(z.number()), test/env.test.ts:656: option(name("port"), schema(z.number())), test/route.test.ts:16: option(name("port"), schema(type("number"))), test/route.test.ts:42: option(name("port"), schema(type("number"))), test/route.test.ts:51: option(name("port"), schema(type("number"))), test/route.test.ts:56: expectType<Equal<ModelOf, { port: number }>>(true); test/route.test.ts:62: option(name("port"), schema(type("number"))), test/route.test.ts:65: expectType<Equal<ModelOf, { port: number }>>(true); test/route.test.ts:71: option(name("port"), schema(type("number"))), test/route.test.ts:76: Equal<ModelOf, { port: number; domain: string }> test/route.test.ts:81: readonly [Done<{ port: number; domain: string }, []>] test/route.test.ts:94: Equal<ModelOf, { config: string; dryRun: boolean }> test/route.test.ts:99: readonly [Done<{ config: string; dryRun: boolean }, []>] test/route.test.ts:107: option(name("port"), schema(type("number"))), test/route.test.ts:134: let count = (_value: typeof start): number => 1; test/route.test.ts:138: // @ts-expect-error label cannot consume the number returned by count. test/route.test.ts:165: option(name("two"), schema(type("number"))), test/route.test.ts:166: option(name("three"), schema(type("boolean"))), test/route.test.ts:168: option(name("five"), schema(type("number"))), test/route.test.ts:169: option(name("six"), schema(type("boolean"))), test/route.test.ts:171: option(name("eight"), schema(type("number"))), test/route.test.ts:172: option(name("nine"), schema(type("boolean"))), test/route.test.ts:174: option(name("eleven"), schema(type("number"))), test/route.test.ts:175: option(name("twelve"), schema(type("boolean"))), test/route.test.ts:177: option(name("fourteen"), schema(type("number"))), test/route.test.ts:178: option(name("fifteen"), schema(type("boolean"))), test/route.test.ts:180: option(name("seventeen"), schema(type("number"))), test/route.test.ts:181: option(name("eighteen"), schema(type("boolean"))), test/route.test.ts:183: option(name("twenty"), schema(type("number"))), test/route.test.ts:184: option(name("twentyOne"), schema(type("boolean"))), test/route.test.ts:186: option(name("twentyThree"), schema(type("number"))), test/route.test.ts:187: option(name("twentyFour"), schema(type("boolean"))), test/route.test.ts:189: option(name("twentySix"), schema(type("number"))), test/route.test.ts:190: option(name("twentySeven"), schema(type("boolean"))), test/route.test.ts:192: option(name("twentyNine"), schema(type("number"))), test/route.test.ts:193: option(name("thirty"), schema(type("boolean"))), test/route.test.ts:201: two: number; test/route.test.ts:202: three: boolean; test/route.test.ts:204: five: number; test/route.test.ts:205: six: boolean; test/route.test.ts:207: eight: number; test/route.test.ts:208: nine: boolean; test/route.test.ts:210: eleven: number; test/route.test.ts:211: twelve: boolean; test/route.test.ts:213: fourteen: number; test/route.test.ts:214: fifteen: boolean; test/route.test.ts:216: seventeen: number; test/route.test.ts:217: eighteen: boolean; test/route.test.ts:219: twenty: number; test/route.test.ts:220: twentyOne: boolean; test/route.test.ts:222: twentyThree: number; test/route.test.ts:223: twentyFour: boolean; test/route.test.ts:225: twentySix: number; test/route.test.ts:226: twentySeven: boolean; test/route.test.ts:228: twentyNine: number; test/route.test.ts:229: thirty: boolean; test/route.test.ts:243: R["methods"][number]; test/command.test.ts:24: option(name("port"), schema(type("number"))), test/command.test.ts:32: Equal<ModelOf, { port: number; domain: string }> test/command.test.ts:37: readonly [Done<{ port: number; domain: string }, []>] test/command.test.ts:45: option(name("config"), multiple(), schema(type("string[]"))), test/command.test.ts:56: multiple(), test/command.test.ts:69: option(name("config"), multiple(), schema(type("string"))), test/command.test.ts:78: option(name("config"), multiple(), schema(type("string"))), test/command.test.ts:83: option(name("config"), schema(type("string")), multiple()), test/command.test.ts:90: option(name("p1"), schema(type("number"))), test/command.test.ts:91: option(name("p2"), schema(type("number"))), test/command.test.ts:92: option(name("p3"), schema(type("number"))), test/command.test.ts:93: option(name("p4"), schema(type("number"))), test/command.test.ts:94: option(name("p5"), schema(type("number"))), test/command.test.ts:95: option(name("p6"), schema(type("number"))), test/command.test.ts:96: option(name("p7"), schema(type("number"))), test/command.test.ts:97: option(name("p8"), schema(type("number"))), test/command.test.ts:98: option(name("p9"), schema(type("number"))), test/command.test.ts:99: option(name("p10"), schema(type("number"))), test/command.test.ts:100: option(name("p11"), schema(type("number"))), test/command.test.ts:101: option(name("p12"), schema(type("number"))), test/command.test.ts:102: option(name("p13"), schema(type("number"))), test/command.test.ts:103: option(name("p14"), schema(type("number"))), test/command.test.ts:104: option(name("p15"), schema(type("number"))), test/command.test.ts:105: option(name("p16"), schema(type("number"))), test/command.test.ts:106: option(name("p17"), schema(type("number"))), test/command.test.ts:107: option(name("p18"), schema(type("number"))), test/command.test.ts:108: option(name("p19"), schema(type("number"))), test/command.test.ts:109: option(name("p20"), schema(type("number"))), test/command.test.ts:110: option(name("p21"), schema(type("number"))), test/command.test.ts:111: option(name("p22"), schema(type("number"))), test/command.test.ts:112: option(name("p23"), schema(type("number"))), test/command.test.ts:113: option(name("p24"), schema(type("number"))), test/command.test.ts:114: option(name("p25"), schema(type("number"))), test/command.test.ts:115: option(name("p26"), schema(type("number"))), test/command.test.ts:116: option(name("p27"), schema(type("number"))), test/command.test.ts:117: option(name("p28"), schema(type("number"))), test/command.test.ts:118: option(name("p29"), schema(type("number"))), test/command.test.ts:119: option(name("p30"), schema(type("number"))), test/command.test.ts:124: p1: number; test/command.test.ts:125: p2: number; test/command.test.ts:126: p3: number; test/command.test.ts:127: p4: number; test/command.test.ts:128: p5: number; test/command.test.ts:129: p6: number; test/command.test.ts:130: p7: number; test/command.test.ts:131: p8: number; test/command.test.ts:132: p9: number; test/command.test.ts:133: p10: number; test/command.test.ts:134: p11: number; test/command.test.ts:135: p12: number; test/command.test.ts:136: p13: number; test/command.test.ts:137: p14: number; export type Decoder = (value: string) => unknown[];

export const number: Decoder = (value) => { if (!numeric.test(value)) { return []; }

let decoded = Number(value); return Number.isFinite(decoded) ? [decoded] : []; };

export const scalar: Decoder = (value) => { return [...number(value), value]; };

export const boolean: Decoder = (value) => { if (value === "true") { return [true]; } if (value === "false") { return [false]; } return []; };

const numeric = /^-?\d+(?:.\d+)?(?:[eE][+-]?\d+)?$/; import { type Param, param } from "./param.ts"; import { brand, type Check, type Fold, type ParamElement, type Unary, } from "./pipeline.ts"; import type { CLIRead, ReadCLI } from "./read.ts"; import type { Word } from "./tokenize.ts"; import type { AnyRoute, Definition } from "./types.ts";

export function argument< const N extends string, const E extends readonly Unary[],

( named: Definition, ...elements: E & Check<Param<N, unknown>, E> ): ElementOf<N, Fold<Param<N, unknown>, E>> { const added = elements.reduce( (value, element) => element(value as never), param(named, positional), ) as Param<string, unknown>;

return brand<ElementOf<N, Fold<Param<N, unknown>, E>>>( (route: AnyRoute) => { let phases = [...route.phases]; let phase = phases.pop()!; phases.push({ ...phase, params: { ...phase.params, [added.name]: added, }, });

  return {
    ...route,
    phases,
  };
},

); }

type ValueOf

= P extends Param<string, infer T> ? T : never;

type ElementOf<N extends string, P> = P extends Param<N, unknown> ? ParamElement<N, ValueOf

> : never;

function positional<P extends Param<string, unknown>>(param: P): P { return { ...param, cli: { read, syntax: { type: "argument", label: <${param.name.toUpperCase()}>, }, }, }; }

const read: ReadCLI = (tokens): CLIRead => { let claim = tokens.claimOne((token): token is Word => token.type === "word"); let [word] = claim.tokens;

return word ? { claim, result: { ok: true, value: { exists: true, value: word.text }, issues: [], }, } : nothing(tokens); };

function nothing(tokens: Parameters[0]): CLIRead { let claim = tokens.claimAll(() => false);

return { claim, result: { ok: true, value: { exists: false }, issues: [], }, }; } import { boolean as decode } from "./decode.ts"; import { dasherize } from "./dasherize.ts"; import { type Param, param, schema } from "./param.ts"; import { brand, type Check, type Fold, type ParamElement, type Unary, } from "./pipeline.ts"; import type { CLIRead, ReadCLI } from "./read.ts"; import type { Flag } from "./tokenize.ts"; import type { AnyRoute, Definition, Schema } from "./types.ts";

export function toggle< const N extends string, const E extends readonly Unary[],

( named: Definition, ...elements: E & Check<Param<N, boolean>, E> ): ElementOf<N, Fold<Param<N, boolean>, E>> { const added = elements.reduce( (value, element) => element(value as never), { ...param(named, binding(named.name), schema(bool)), decode, }, ) as Param<string, unknown>;

return brand<ElementOf<N, Fold<Param<N, boolean>, E>>>( (route: AnyRoute) => { let phases = [...route.phases]; let phase = phases.pop()!; phases.push({ ...phase, params: { ...phase.params, [added.name]: added, }, });

  return {
    ...route,
    phases,
  };
},

); }

type ValueOf

= P extends Param<string, infer T> ? T : never;

type ElementOf<N extends string, P> = P extends Param<N, unknown> ? ParamElement<N, ValueOf

> : never;

function binding( name: string, ): <P extends Param<string, unknown>>(param: P) => P { const stem = dasherize(name); const yes = --${stem}; const no = --no-${stem};

return (param) => ({ ...param, cli: { read: reader(name), syntax: { type: "option", label: ${yes}, ${no}, }, }, }); }

function reader(name: string): ReadCLI { const stem = dasherize(name); const yes = --${stem}; const no = --no-${stem};

return (tokens): CLIRead => { let claim = tokens.claimOne((token): token is Flag => { return token.type === "flag" && (token.text === yes || token.text === no); }); let [flag] = claim.tokens;

return flag
  ? {
    claim,
    result: {
      ok: true,
      value: { exists: true, value: flag.text === yes },
      issues: [],
    },
  }
  : {
    claim,
    result: {
      ok: true,
      value: { exists: false },
      issues: [],
    },
  };

}; }

const bool: Schema = { "~standard": { version: 1, vendor: "configliere", validate(value) { return typeof value === "undefined" ? { value: false } : typeof value === "boolean" ? { value } : { issues: [{ message: "expected boolean" }] }; }, }, }; export type Maybe = { readonly exists: false; } | { readonly exists: true; readonly value: T; };

export function Just(): Maybe; export function Just(value: T): Maybe; export function Just(value?: T): Maybe<T | undefined> { if (typeof value === "undefined") { return { exists: true } as Maybe; } else { return { exists: true, value }; } }

export function Nothing<T = void>(): Maybe { return nothing as Maybe; }

const nothing = { exists: false }; import type { Maybe } from "./maybe.ts"; import type { Param } from "./param.ts"; import { brand, type IdentityElement } from "./pipeline.ts"; import type { Result } from "./result.ts"; import type { Flag, Setter, Word } from "./tokenize.ts"; import type { Claim, TokenInput } from "./tokenizer.ts";

export type Symbol = Flag | Setter | Word;

export type ReadCLI = ( tokens: TokenInput, ) => CLIRead;

export interface CLIBinding { readonly read: ReadCLI; readonly syntax?: CLISyntax; }

export type CLISyntax = | { readonly type: "argument"; readonly label: string } | { readonly type: "option"; readonly label: string };

export interface CLIRead { result: Result<Maybe<string | boolean>>; claim: Claim; }

export interface CLIOptions { switch?: true; }

export function cli( names: readonly string[], options: CLIOptions = {}, ): IdentityElement<Param<string, unknown>> { const read: ReadCLI = (tokens) => { if (options.switch) { let s = tokens.claimOne((t): t is Flag => { return t.type === "flag" && names.includes(t.text); }); let [flag] = s.tokens; return flag ? { result: { ok: true, value: { exists: true, value: true }, issues: [], }, claim: s, } : nothing(tokens); } let setter = tokens.claimOne((token): token is Setter => { return (token.type === "setter" && names.includes(--${token.nameText})); }); let [token] = setter.tokens; if (token) { return { claim: setter, result: { ok: true, value: { exists: true, value: token.valueText, }, issues: [], }, }; } let pair = tokens.claimPair((name, value) => { return name.type === "flag" && names.includes(name.text) && value.type === "word"; }); let [, value] = pair.tokens; if (value) { return { claim: pair, result: { ok: true, value: { exists: true, value: value.text, }, issues: [], }, }; } let bare = tokens.claimOne((t): t is Flag => { return t.type === "flag" && names.includes(t.text); }); let [incomplete] = bare.tokens; if (incomplete) { return { claim: bare, result: { ok: false, issues: [{ message: ${incomplete.text} requires a value, }], }, }; } return nothing(tokens); };

return brand<IdentityElement<Param<string, unknown>>>( (param: Param<string, unknown>) => ({ ...param, cli: { read, syntax: { type: "option", label: options.switch ? names.join(", ") : ${names.join(", ")} <VALUE>, }, }, }), ); }

▶ ✶ Thinking

Designing lifted decoder integration

Refining decoder interface and cardinality handling

Considering batch decode for multiple switches

Deciding route parameter binding timing

Refining capture collection and error handling

Designing error continuation and decoding rules

Confirming schema authoritative output

Documenting binding updates without code changes

The change is fairly contained. PR #32 already has the correct capture loop; the main work is replacing Cartesian candidate generation with a fixed-width decoder and making phase binding explicitly capture-then-finalize.

  1. Make decoder output fixed-width

Today:

ts ⧉

export type Decoder = (value: string) => unknown[];

A failed alternative disappears, so decoder positions are lost.

Change it to:

ts ⧉

import type { Maybe } from "./maybe.ts";

export type Decoder<T = unknown> = ( value: string, ) => readonly Maybe[];

Then update the built-ins:

ts ⧉

export const number: Decoder = (value) => { if (!numeric.test(value)) { return [Nothing()]; }

let decoded = Number(value);

return Number.isFinite(decoded) ? [Just(decoded)] : [Nothing()]; };

export const text: Decoder = (value) => [ Just(value), ];

export const scalar: Decoder = (value) => [ ...number(value), ...text(value), ];

export const boolean: Decoder = (value) => [ value === "true" ? Just(true) : value === "false" ? Just(false) : Nothing(), ];

The critical invariant becomes:

A decoder always returns the same number of slots, in the same semantic order.

For scalar, slot zero is always numeric and slot one is always textual:

ts ⧉

scalar("abc") // [Nothing, Just("abc")]

scalar("123") // [Just(123), Just("123")]

No explicit identity or branding is needed.

  1. Implement the multiple decoder

Add a decoder transformation, probably in lib/decode.ts:

ts ⧉

export function multiple( decode: Decoder, ): (values: readonly string[]) => readonly Maybe<readonly T[]>[] { return (values) => { let rows = values.map(decode); let width = rows[0]?.length ?? 0;

return Array.from({ length: width }, (_, index) => {
  let column = rows.map((row) => row[index]);

  if (column.every(present)) {
    return Just(column.map((candidate) => candidate.value));
  }

  return Nothing();
});

}; }

Given:

ts ⧉

multiple(scalar)(["abc", "123"])

it returns:

ts ⧉

[ Nothing, Just(["abc", "123"]), ]

Given:

ts ⧉

multiple(scalar)(["001", "002"])

it returns:

ts ⧉

[ Just([1, 2]), Just(["001", "002"]), ]

Candidate count is now bounded by decoder width, not repetitions.

The empty-input case does not need to be decoded. An absent CLI source should continue through Env, Values, and finally schema validation with undefined.

  1. Remove Cartesian generation from bind.ts

Delete the current recursive generator:

ts ⧉

function* candidates( param, values, index = 0, prefix = [], ) { // Cartesian product }

Replace decodeMany() with the lifted decoder:

ts ⧉

function decodeMany( param: Param<string, T>, values: readonly string[], path: string[], ): Result { return decode( param, values, multiple(param.decode)(values), path, ); }

decode() must now accept Maybe candidates and skip Nothing while retaining the slot structure supplied by the decoder:

ts ⧉

function decode( param: Param<string, T>, value: unknown, candidates: Iterable<Maybe>, path: string[], ): Result { let issues: readonly Issue[] | undefined; let found = false;

for (let candidate of candidates) { if (!candidate.exists) { continue; }

found = true;

let result = validate(param, candidate.value, path);
if (result.ok) {
  return result;
}

issues = issues ?? result.issues;

}

return found ? { ok: false, issues: issues ?? [] } : unableToDecode(value, path); }

Singular decoding uses the same path:

ts ⧉

decode(param, value, param.decode(value), path)

That is useful: singular and multiple candidates now have exactly the same shape.

  1. Make binding explicitly capture, then interpret

PR #32 currently does this only for successful multiple reads:

text ⧉

read → accumulate → decode after loop

I would make the separation explicit for every CLI parameter:

text ⧉

route search ↓ claim CLI representations until fixed point ↓ close current phase/route segment ↓ decode and validate collected representations ↓ try Env and Values only for parameters absent from CLI

Internally, bindPhase() would maintain something like:

ts ⧉

interface Capture { readonly values: unknown[]; readonly issues: Issue[]; readonly exists: boolean; readonly failed: boolean; }

During the arbitration loop:

• Readers only propose and claim representations. • No schema is invoked. • Singular parameters stop offering after their first existing read. • Multiple parameters continue offering until the fixed point. • Successful-read diagnostics accumulate. • Failed-read diagnostics also accumulate. • Any existing CLI capture—successful or failed—blocks Env and Values.

After the loop:

ts ⧉

for (let param of params) { let capture = captures.get(param.name);

if (!capture?.exists) { // Try Env, Values, undefined. } else if (capture.failed) { // Return every capture issue. } else if (param.multiple) { // Decode the complete representation array. } else { // Decode the single representation. } }

This would also resolve the diagnostic edge we discussed. A later malformed occurrence would not bypass earlier successful-read warnings.

  1. Define what “closed” means

The documentation should avoid saying the entire final route must be known. In a dynamic parse that may not yet be possible.

The exact boundary is:

A parameter is interpreted after its current phase’s CLI binding scope reaches a fixed point.

For:

text ⧉

--config one --config two auth0 --port 9000 ^ horizon

the current phase collects one and two, stops at auth0, and then decodes the collected values. It must not inspect --port 9000, because a later phase may establish auth0 as a child route.

So:

Capture is incremental; interpretation happens when the current binding scope closes.

  1. Keep schema output authoritative

The latest PR #32 revision correctly changed MultipleParam so that multiple() does not mechanically turn the model type into T[].

Keep that.

ts ⧉

option( name("value"), multiple(), schema(type("string[]")), ) // model: { value: string[] }

A transforming schema may accept the array candidate and return something else:

ts ⧉

option( name("value"), multiple(), schema(arrayToSet), ) // model: { value: Set }

multiple() changes source capture and decoding cardinality. The schema still determines the model value type.

  1. Decide the non-string representation rule

CLI readers can currently return string | boolean, and custom readers may eventually return other physical representations.

For non-string captures, the current implementation bypasses decoding. The corresponding multiple rule should be explicit. The simplest rule is:

ts ⧉

// singular non-text representation [Just(value)]

// multiple non-text representations [Just(values)]

That means switches or custom already-interpreted representations have one unambiguous candidate.

If multiple() is intended only for valued options, reject it statically or during definition instead. Either choice is reasonable; leaving it implicit is not.

  1. Tests to add or change

Decoder slot preservation

ts ⧉

expect(scalar("abc")).toEqual([ Nothing(), Just("abc"), ]);

expect(scalar("123")).toEqual([ Just(123), Just("123"), ]);

Multiple lifting

ts ⧉

expect(multiple(scalar)(["001", "002"])).toEqual([ Just([1, 2]), Just(["001", "002"]), ]);

expect(multiple(scalar)(["abc", "123"])).toEqual([ Nothing(), Just(["abc", "123"]), ]);

Observable string preservation

Keep the existing parse test:

ts ⧉

--config 0012 --config 0034

with:

ts ⧉

schema(type("string[]"))

and expect:

ts ⧉

["0012", "0034"]

Bounded candidate validation

Use a schema that counts validations and rejects every candidate. With twenty numeric-looking representations, validation should happen at most twice—not (2^{20}) times.

Although we normally avoid implementation call-count tests, here the bounded candidate set is itself the behavioral safety contract. Alternatively, test multiple(scalar) directly and assert that its result has exactly two slots.

The latter is cleaner.

Route boundary

Keep the existing dynamic child test proving a repeated parent option cannot capture child options.

Diagnostics

Add a custom reader test where:

  1. An earlier successful occurrence produces a warning.
  2. A later occurrence fails.
  3. Both diagnostics survive in the final issue list.

Also decide whether several malformed repeated occurrences should all be claimed and reported, or whether the first failure closes that parameter.

  1. Document two invariants

I would add these to docs/binding.md:

Decoder candidate positions are stable. A missing interpretation is represented explicitly rather than removing its candidate slot.

Repeated capture lifts each decoder candidate across the complete representation collection. It never forms mixed Cartesian combinations between decoder candidates.

And under binding:

CLI representations are captured until the current binding scope reaches a fixed point. Decoding and schema validation occur only after that scope closes.

Net effect

The main implementation change is small:

• Fixed-width Maybe decoder results. • A multiple(decoder) lifting function. • Delete Cartesian candidate generation. • Finalize captures after the CLI fixed point. • Preserve capture diagnostics through failure.

The reader and horizon fix in PR #32 should remain. That part already establishes the correct incremental collection mechanism.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment