Skip to content

New flag Constraints API Proposal #1507

@AllanOricil

Description

@AllanOricil

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

  1. 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
  1. Manual validation in run()
  • extremely verbose
  • not reusable
  • bypasses Oclif’s declarative philosophy
    error messages inconsistent across commands
  1. 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);
  }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions