Skip to main content

Command input schema contract

Implemented — not released

This document describes the implemented command input schema contract. These additions are not included in @zeltjs/core@0.11.0. See Commands for a usage overview.

Declare the input contract once in a schema. Zelt derives input validation, TypeScript input types, and help from that declaration. The new APIs are also exported from @zeltjs/core. Zelt implements the declarations, parsing, type inference, validation, and help generation.

Receive input through a schema​

Extend the existing cliSchema({ args, options }) declarations. Define the schema as a module-level constant and pass it to args(schema) in a default parameter of run().

import { Command, args, cliSchema } from '@zeltjs/core';

const checkGuideSchema = cliSchema({
  options: [
    { name: 'snapshot', type: 'string', required: true },
    { name: 'guide', type: 'string', required: true },
  ],
});

@Command({ name: 'check-guide' })
export class CheckGuideCommand {
  run(input = args(checkGuideSchema)) {
    // input.snapshot: string
    // input.guide: string
    console.log(input.snapshot, input.guide);
  }
}

As with HTTP's request(schema), passing the schema infers the input type. Users do not register the same schema again on @Command or annotate the input type of run() manually. Constructor dependency injection through inject() remains available.

Required options​

required: true requires the option to be explicitly supplied on the command line. Either its long name or its alias satisfies this requirement. If it is omitted, args(schema) does not return input.

DeclarationWhen omittedInferred type
type: 'string'undefinedstring | undefined
type: 'string', required: trueInput errorstring
type: 'number'undefinednumber | undefined
type: 'number', required: trueInput errornumber
type: 'boolean'falseboolean
type: 'boolean', required: trueInput errorboolean
type: 'string', default: 'main''main'string
type: 'number', default: 30003000number
type: 'boolean', default: truetrueboolean

A string or number option supplied without a value is an input error. Values that cannot be converted to a number, or produce a non-finite number, are also rejected. An explicitly supplied empty string counts as a supplied string option. Domain validation, such as string length or numeric ranges, is separate from this requirement.

The contract for checkGuideSchema above is:

Command inputResult
check-guide --snapshot snapshot.json --guide guide.json{ snapshot: 'snapshot.json', guide: 'guide.json' }
check-guide --guide guide.json --snapshot snapshot.jsonThe same input values
check-guide --snapshot snapshot.jsonRejected: required --guide is missing
check-guide --snapshot snapshot.json --guideRejected: --guide has no value
check-guide --snapshot snapshot.json --guide guide.json extraRejected: undeclared positional argument

Contract: Combining required: true with default is a schema declaration error. Requiring explicit input and supplying a value when omitted are separate contracts. Existing defaults without required: true retain their behavior.

Variadic positional arguments​

variadic: { min, max } collects the remaining positional arguments into an array.

import { cliSchema } from '@zeltjs/core';

const analyzeSchema = cliSchema({
  args: [
    {
      name: 'directories',
      type: 'string',
      variadic: { min: 1 },
      description: 'Directories to analyze',
    },
  ],
});
pathdency src packages/a packages/b

This input produces { directories: ['src', 'packages/a', 'packages/b'] }. Array elements preserve input order.

DeclarationAllowed countWhen omittedInferred type
type: 'string', variadic: { min: 0 }Zero or more[]string[]
type: 'string', variadic: { min: 1 }One or moreInput errorstring[]
type: 'number', variadic: { min: 1, max: 3 }One to threeInput errornumber[]

The count is validated at runtime. Zelt does not generate tuple types from min or max. Numeric variadic arguments convert and validate every element; one invalid element rejects the entire input.

Contract: Omitted min means 0; omitted max means no upper limit.

Declarations satisfy these conditions:

  • Each schema has at most one variadic argument, placed last in args. Fixed positional arguments may precede it.
  • min and max are finite non-negative integers. When both are supplied, min <= max.
  • A variadic argument cannot also have optional: true. Use min: 0 to allow zero elements.
  • Positional arguments without variadic remain single values. optional: true retains its existing meaning.

Assign fixed positional arguments in declaration order, then pass the remaining values to the variadic argument. Do not skip and reassign a preceding fixed argument to satisfy the variadic argument's min.

For example, variadic: { min: 1, max: 3 } rejects zero or four elements and accepts one or three. Excess input is neither discarded nor truncated.

Declare valid inputs as a union​

Express mutual exclusion by declaring each valid input shape as a separate schema and passing those schemas to cliUnion().

import { Command, args, cliSchema, cliUnion } from '@zeltjs/core';

const snapshotSchema = cliSchema({
  options: [
    { name: 'snapshot', type: 'string', required: true },
  ],
});

const directoriesSchema = cliSchema({
  args: [
    { name: 'directories', type: 'string', variadic: { min: 0 } },
  ],
  options: [
    { name: 'base', type: 'string' },
  ],
});

const configSchema = cliSchema({
  options: [
    { name: 'config', type: 'string', required: true },
    { name: 'base', type: 'string' },
  ],
});

const serveSchema = cliUnion([
  snapshotSchema,
  directoriesSchema,
  configSchema,
]);

@Command({ name: 'serve' })
export class ServeCommand {
  run(input = args(serveSchema)) {
    if (input.snapshot !== undefined) {
      // input.snapshot: string
      console.log('snapshot', input.snapshot);
    } else if (input.config !== undefined) {
      // input.config: string
      // input.base: string | undefined
      console.log('config', input.config, input.base);
    } else {
      // input.directories: string[]
      // input.base: string | undefined
      console.log('directories', input.directories, input.base);
    }
  }
}

Branch selection​

Validate each branch against the entire input, excluding the command name. A branch rejects input undeclared in that branch, even when another branch declares it. Do not flatten branch declarations or return only the parts that parsed successfully.

Command inputResult
serve --snapshot saved.jsonSnapshot branch; snapshot is 'saved.json'
serve src packages/aDirectories branch; directories is ['src', 'packages/a']
serve src --base mainDirectories branch; base is 'main'
serve --config pathdency.json --base mainConfig branch; returns config and base
serveDirectories branch; directories is [], base is undefined
serve --base mainDirectories branch; directories is [], base is 'main'
serve --snapshot saved.json --base mainRejected: no branch accepts the entire input
serve --snapshot saved.json srcSame rejection
serve src --config pathdency.jsonSame rejection
serve --snapshot saved.json --config pathdency.jsonSame rejection
serve --snapshop saved.jsonRejected: undeclared option

Reordering options does not change whether the same combination is accepted or rejected.

Contract: Exactly one branch must accept the entire input. Zero matching branches is an input error; multiple matching branches is an ambiguous input error. The first branch has no priority. For example, a union of two schemas containing only optional options rejects empty input because both branches match. Reordering branches does not change the result.

Defaults apply only to omitted options within their own branch. A failed branch never contributes values to another branch. If the snapshot branch is accepted, a default for base in an analysis branch is not added to the result. A default also cannot satisfy required: true.

Inferred input type​

args(serveSchema) returns the union of the branch input types. Properties absent from a branch are represented as ?: never in its type and are not added to that branch's result. The example above infers this shape:

type ServeInput =
  | {
      snapshot: string;
      directories?: never;
      config?: never;
      base?: never;
    }
  | {
      directories: string[];
      base: string | undefined;
      snapshot?: never;
      config?: never;
    }
  | {
      config: string;
      base: string | undefined;
      snapshot?: never;
      directories?: never;
    };

This type is illustrative. Users do not need to declare it or annotate run() with it. InferSchema<typeof serveSchema> also produces the same type. No undeclared discriminator, such as mode, is added automatically.

Reject undeclared input​

cliSchema() and cliUnion() always reject unknown options and excess positional arguments. There is no strict switch.

InputContract
Declared option name or aliasValidate its declared type and required condition
Undeclared long option or aliasReject, whether or not it has a value
Input exceeding the fixed positional argument countReject
Input exceeding a variadic argument's maxReject
Tokens after --Treat as positional arguments and apply the same type and count validation

For example, cliSchema({}) accepts only empty input and rejects --unknown or positional arguments. A schema declaring one positional argument rejects first second rather than discarding the second value.

When args(schema) is a default parameter of run(), an input error prevents entry into the method body. execCommand() returns the existing failure result: exitCode: 1 and a ZeltCommandExecutionError with context.reason: 'argv_parse_error'. Users do not add required-option, count, exclusion, or unknown-name checks to run().

Error descriptions identify causes needed to correct the input, such as a missing option name, an invalid value, allowed and supplied counts, or an incompatible input combination. A union error does not stop at "no matching branch." Exact wording is not part of the contract.

This contract covers validation through args(schema). Automatically applying a schema to a command that never calls args() is outside this contract.

Derive help from the same schema​

Help reflects declared names, aliases, types, descriptions, required or optional inputs, defaults, and variadic counts. Unions show a separate usage alternative for each branch, rather than suggesting that inputs from different branches can be freely combined.

Usage:
  check-guide --snapshot <snapshot> --guide <guide>

Usage:
  serve --snapshot <snapshot>
  serve [directories...] [--base <base>]
  serve --config <config> [--base <base>]

<directories...> means one or more elements; [directories...] means zero or more. For min: 1, max: 3, help also states "one to three elements." The contract requires users to see the available inputs and required conditions for each alternative. Whitespace and styling are not fixed.

formatCommandHelp(commandName, schema) returns a help string derived from the same schema. It does not parse input, execute the command, or write to stdout. Automatic handling of --help has not been added.

import { cliSchema, formatCommandHelp } from '@zeltjs/core';

const schema = cliSchema({
  args: [{ name: 'directories', type: 'string', variadic: { min: 1, max: 3 } }],
});
console.log(formatCommandHelp('pathdency', schema));

Schema declaration validity​

Distinguish user input errors from developer schema declaration errors. An invalid schema must not accept input.

  • Output name values are unique across positional arguments and options within a branch.
  • Option names and aliases do not collide with other option names or aliases within a branch.
  • Each default matches its declared type.
  • required with default, and variadic placement, counts, and optional, satisfy the conditions above.
  • cliUnion() has at least two alternatives. Different branches may reuse names and aliases.

Reject inconsistencies detectable from literals in TypeScript wherever possible. Schemas built from dynamic values satisfy the same conditions, checked at runtime. cliSchema(), cliUnion(), and formatCommandHelp() throw ZeltAppConfigurationError with context.reason: 'invalid_command_schema' for declaration errors. Duplicate names or aliases and variadic placement are checked from literals in TypeScript; numeric bounds are checked at runtime.

Implemented decisions and scope​

The implemented contract includes these four decisions:

  1. Combining required: true with default is a schema declaration error.
  2. Omitted variadic min means zero and omitted max means no upper limit; inferred types remain arrays.
  3. Multiple matching union branches are rejected, with no priority based on declaration order.
  4. cliUnion() requires at least two schemas.

The existing run(input = args(schema)) style and constructor DI are preserved. Automatic --help handling and automatic validation of commands that do not use a schema have not been added.