Repository navigation
Declarative cross-flag relationships: typed .constrain() block and object-level .validate() seam #91
Description
v4 release scope decision
Deferred from v4.0.0 to v4.x. #90 has shipped, so provenance is available; this issue no longer blocks stable.
Implement as opt-in command-level constraints and object-level validation, preserving behavior for commands that do not register them. Keep per-input resolution and existing derive/middleware ordering unchanged. Add optional definition-document fields; do not change required consumer input shapes or existing closed unions/DTOs. Constraint failures can use the open ErrorCode contract without extending the closed ValidationErrorCode union in a minor release.
This issue owns the .validate() design also discussed in #15. Do not independently ship two incompatible APIs with that name. The typed constraint builder and Standard Schema object-validation seam remain the direction; reconcile any convenience callback before introducing the new API. If implementation requires changing an existing stable contract, split that change into a future major release instead of treating it as v4.x work.
Sub-issue of #88 — Part 2. Depends on #90.
Problem
Flags are resolved independently and relationships between them are not expressible at all. The docs say so plainly: there is no .exclusive(), no .conflicts(), and no required-one-of group.
What is missing:
- conflicts —
--output×--keep-name - requires / implies —
--encodingis meaningless without--embed;--asset-dironly applies under--embed - exactly-one-of / at-most-one-of — transport selection
- all-or-none —
--certand--keytogether or neither - conditional requirement —
--tokenrequired only when--remoteis set
Today each is a hand-written if in .derive(). The cost is not the if; it is that the rule exists only in the handler:
- help cannot say "conflicts with
--keep-name" - the definition document /
--help --jsoncannot express it, so no downstream tool can - shell completions still offer a flag that conflicts with one already on the command line — dreamcli generates completions from the schema, so this is a payoff it is uniquely positioned to deliver and currently cannot
- error codes and messages are reinvented per project
That sits awkwardly against the stated preference for "schema-declared behavior over convention-heavy magic". A rule that exists only as an if inside a handler is the least schema-declared construct available.
Two seams, one design decision
The documented objection — cross-flag rules are open-ended, and a single knob would cover only a slice — is an argument for the right seam, not against having one. Both proposals below run after resolution, over the resolved set, so no flag learns about its siblings and the per-flag independence invariant holds.
D. Command-level typed constraint block
The ergonomic path. Declared after the .flag() calls, so the full keyof F / keyof A is known and typos fail the build:
command('generate')
.flag('output', flag.string())
.flag('keep-name', flag.boolean())
.flag('embed', flag.boolean())
.flag('encoding', flag.enum(['base64', 'utf8']))
.constrain((c) => [
c.conflicts('output', 'keep-name'),
c.requires('encoding', 'embed'),
c.exactlyOne('stdio', 'socket', 'node-ipc'),
c.allOrNone('cert', 'key'),
]);Flag-level modifiers (flag.boolean().conflicts('output'), shape C in #88) are explicitly not the chosen route: they push relationship knowledge down into individual flag builders, which is precisely what the invariant rules out, and their inability to type sibling names is a symptom of that rather than the root problem.
E. Object-level Standard Schema
The open-ended escape hatch. Standard Schema v1 support currently reaches individual values only (flag.custom, arg.custom, per array element); there is no seam for validating the resolved object as a whole, which is the shape every cross-field rule needs:
.validate(
z.object({ output: z.string().optional(), 'keep-name': z.boolean() })
.refine((v) => !(v.output !== undefined && v['keep-name']), {
message: '--output and --keep-name both name the ICO',
path: ['output'],
}),
);Exactly-one, at-most-one, all-or-none, implies, and anything nobody has thought of yet are all just refinements, using validators consumers already depend on. StandardSchemaV1Issue.path maps onto flag names so issues can render as per-flag diagnostics — StandardSchemaV1PathSegment is already exported.
D and E share one decision: where the post-resolution validation pass runs and how violations become framework CLIErrors. That is why they are tracked together, even if they land as separate PRs.
Acceptance criteria
- Post-resolution constraint pass with access to provenance — needs Value provenance: track resolution stage and expose explicitness to handlers #90, since an object-level schema on its own sees resolved values and hits the Part 1 wall
- Constraint names compile-checked against the declared flags/args
- Framework-standard error codes (
CONFLICTING_FLAGS,MISSING_DEPENDENT_FLAG, …) instead of per-project ad-hoc ones - Constraints serialized into the definition document /
--help --json - Help renders relationships, e.g. "conflicts with
--keep-name" - Completions suppress flags that conflict with one already on the command line
- Docs: replace the Limitations "Exactly One Of Is Not Built In"
.derive()workaround
Concrete case from #88: generate in kjanat/vite-svg-to-ico has --output and --keep-name, both answering what the file is called. -o favicon.ico --keep-name is a real conflict that no current workaround can detect.
Activity
coderabbitai commentedon Aug 1, 2026
🔗 Related PRs
#12 - add: prompt-flag compatibility validation [closed]
#54 - fix(cli): consistent root
--helpreach and advertise built-in global flags [merged]#57 - feat: themed help output + negatable/duplicates/case-parity flags (rc.8) [merged]
#58 - feat: compile-time array-element narrowing + JSON help output [merged]
#60 - feat(schema): Standard Schema interop and DSL removal [merged]
📝 Issue Planner
Check the box below or use the
@coderabbitai plancommand to generate an implementation plan and prompts that you can use with your favorite coding assistant.🧪 Issue enrichment is currently in open beta.
To disable automatic issue enrichment, add the following to your
.coderabbit.yaml:💬 Have feedback or questions? Drop into our discord!