New flag Constraints API Proposal
Please, before disregarding this idea, validate its pros/cons with other cli developers/builders.
Is your feature request related to a problem? Please describe.
Oclif’s current way of declaring command constraints, using flag atLeastOne, relationships, dependsOn, exclusive, exactlyOne and compatible, forces developers to encode validation rules inside individual flags, which fragments business logic across multiple definitions and increases cognitive load.
This results in:
- duplicated logic (exclusive has to be added in multiple places)
- impossible constraints (e.g., true XOR / “one-of-required”)
- scattered dependency rules (dependsOn, relationships)
- maintenance overhead (one new flag often forces rewriting several others)
- unintuitive validation behavior when multiple rules interact, specially when using
relationships
For anything beyond trivial commands, developers end up manually re-implementing logic inside run(), defeating the purpose of declarative flag relationships.
This creates real friction and makes flag-heavy commands difficult to maintain at scale.
Describe the solution you'd like
Introduce a centralized defineConstraints DSL that declares all flag relationships in a single, declarative structure. This shifts the mental model from “each flag owns its own rules” to “the command defines the rules; flags define types.”
This focuses the developer on business logic instead of scattering rules across the flag map.
Proposed defineConstraints DSL
Example 1 — Simple and readable
import { xor, oneOf, require, forbid, defineConstraints } from '@oclif/constraints';
static constraints = defineConstraints([
xor('upload', 'download').required(),
oneOf('json', 'xml').required(),
require('token').when('login'),
forbid('debug').when('production'),
]);
This DSL compiles into a clear, predictable validation schema (serializable) without the developer needing to understand complex internals.
Example 2 — Mixed DSL + schema hybrid (supported)
static constraints = defineConstraints([
xor('a', 'b').required(),
{
allOf: [['region', 'accountId']]
}
]);
Both syntaxes are supported. DSL is ergonomic; schema is serializable = DSL turns into a structure json that can be serialized.
Describe alternatives you've considered
- Current “relationships” API
- exclusive can prevent two flags from being used together but cannot require that one of them is present
- dependsOn cannot express grouped conditions
- implies cannot express mutual requirements or XOR
- all rules must be duplicated across flags leads to contradictory or incomplete rule sets
- Manual validation in run()
- extremely verbose
- not reusable
- bypasses Oclif’s declarative philosophy error messages inconsistent across commands
- Adding more relationships to flags
- makes flags bloated
- increases cognitive load
- does not fix the fundamental issue: rules belong to commands, not flags
Additional context
Centralization eliminates cognitive load
Today, developers must bounce between 5–10 flags to reconstruct what the command actually expects. With a constraints DSL:
- logic lives in one place
- business rules are explicit
- onboarding new maintainers is trivial
- changes require updating only one file and one structure
The DSL is forward-thinking
It opens the door for:
- auto-generated docs
- static analysis
- validation previews
- better error messages
- cross-command rule sharing
Flags become clean
No more:
required: true
exclusive: ['x', 'y']
dependsOn: ['token']
Flags declare shape and all the other flag api props that aren't related to validations:
Flags.string({ description: "foo", default: "foo"})
Flags.boolean()
Flags.integer()
All logic moves to constraints.
Example
import {
Command,
Flags,
//NOTE: you could add these constraints specific methods under the Constraints namespace inside core to simplify
xor,
oneOf,
atLeast,
require,
forbid,
custom,
defineFlags,
defineConstraints
} from '@oclif/core';
export default class Deploy extends Command {
static description = 'Deploy an application to the selected environment';
//NOTE: new method called defineFlags to ensure typesafety
static flags = defineFlags({
//NOTE: Deployment target
app: Flags.string({ description: 'app name' }),
service: Flags.string({ description: 'service name' }),
//NOTE: Output format
json: Flags.boolean(),
yaml: Flags.boolean(),
//NOTE: AWS details
region: Flags.string(),
profile: Flags.string(),
accessKey: Flags.string(),
secretKey: Flags.string(),
//NOTE: Options
dryRun: Flags.boolean(),
verbose: Flags.boolean(),
pretty: Flags.boolean(),
});
// NOTE: centralize validation rules / command constraints
static constraints = defineConstraints([
//NOTE: Require exactly one deploy target
xor('app', 'service').required().withError('Specify exactly one deploy target: --app or --service'),
//NOTE: Output formats: one required
oneOf('json', 'yaml').required(),
//NOTE: AWS: require either profile OR (accessKey + secretKey)
require('profile').whenNone('accessKey', 'secretKey'),
require(['accessKey', 'secretKey']).whenNone('profile'),
//NOTE: Pretty-print only allowed with YAML
forbid('pretty').when('json'),
//NOTE: Verbose allowed only outside dry-run mode
forbid('verbose').when('dryRun'),
//NOTE: Require region for any deploy
require('region').whenAny('app', 'service'),
//NOTE: At least one tag if YAML output
atLeast('tag', 1).when('yaml'),
//NOTE: Custom edge-case rule
custom(({ flags }) => {
if (flags.accessKey && !flags.secretKey) {
return 'Using --access-key requires --secret-key';
}
}),
]);
//NOTE: By this point, all validations are already verified. That is why parsed flags and constraints results are available in the context of run
async run({ flags, constraints }) {
//NOTE: no longer needed because the core has already parsed flags and made it available in the run context.
//const { flags } = await this.parse(Deploy);
this.log('Deployment starting with flags:', flags);
}
}
Take the Opportunity to declare defineFlags to ensure typesafety. It accepts an object with props of Flag types.
@mdonnalley @cristiand391 @iowillhoit @WillieRuemmele what do you guys think about this new api for v5?
This new API + bun build will make oclif CLIs way cooler
I feel I'm being ignored 🙁
@AllanOricil , thank you very much for your patience. The team is definitely intrigued by this idea, and we're currently in the process of talking it over and hammering out the specifics. We'll keep you posted with updates as and when they become available.
@AllanOricil . The team has decided that this is a great idea, and we're going to move forward with it. We won't be implementing it exactly as you proposed, but it'll be something very much like this proposal, and we're excited for you to get to play with it. We'll be adding it to our backlog, and we'll continue providing updates as they become available. Thanks for the suggestion!
This issue has been linked to a new work item: W-20534184
With flags, args, and constraints available directly in the run method’s context, testing becomes significantly more straightforward. We can assert outcomes in isolation, without coupling tests to parsing or constraint-validation logic. This enables precise injection of inputs and focused assertions on the run method’s behavior itself. The proposed testing approach is illustrated in the accompanying proposal.
https://github.com/oclif/core/issues/1520