capawesome-team/zodline(opens in a new tab)
Language: TypeScript
๐ A powerful CLI parser built with TypeScript and Zod for type-safe command-line interfaces.
- stars
- 16
- watchers
- 1
- forks
- 0
- open issues
- 1
- Last updated
- 10/02/2026
Language: TypeScript
๐ A powerful CLI parser built with TypeScript and Zod for type-safe command-line interfaces.
Build type-safe command-line interfaces with Zod. Declare your commands, options and arguments as schemas โ validation, TypeScript types and help output come for free.
๐ Documentation ยท Quickstart ยท API reference
options and args are inferred from your schemas. Rename a field and every call site fails to compile.--help and --version are handled for you, including per-command help.zodline ships no CommonJS buildnpm install zodline zod
import { z } from 'zod'; import { defineCommand, defineConfig, defineOptions, processConfig } from 'zodline'; const greet = defineCommand({ description: 'Greet someone', options: defineOptions( z.object({ name: z.string().describe('Name to greet'), loud: z.boolean().default(false).describe('Use uppercase'), }), { n: 'name', l: 'loud' }, // Short aliases ), action: async (options) => { // options is typed as { name: string; loud: boolean } const greeting = `Hello, ${options.name}!`; console.log(options.loud ? greeting.toUpperCase() : greeting); }, }); const config = defineConfig({ meta: { name: 'my-cli', version: '1.0.0', description: 'A simple CLI example', }, commands: { greet }, }); const result = processConfig(config, process.argv.slice(2)); await result.command.action(result.options, result.args);
Run it:
$ my-cli greet --name Alice Hello, Alice! $ my-cli greet -n Bob --loud HELLO, BOB!
Help is generated from the same schemas:
$ my-cli --help
A simple CLI example (my-cli v1.0.0)
USAGE my-cli <command>
COMMANDS
greet Greet someone
Use my-cli <command> --help for more information about a command.
$ my-cli greet --help
Greet someone (my-cli greet v1.0.0)
USAGE my-cli greet [OPTIONS]
OPTIONS
--name, -n Name to greet
--loud, -l Use uppercase (default: false)
Commands are a flat record of name to definition. Group related commands with a separator in the name:
const config = defineConfig({ meta: { name: 'my-app', version: '1.0.0' }, commands: { start: startCommand, 'apps:list': appsListCommand, 'apps:create': appsCreateCommand, }, });
Use a space as the separator to get subcommands. The longest matching name wins and the remaining positional
arguments become args:
const config = defineConfig({ meta: { name: 'my-app', version: '1.0.0' }, commands: { 'config set': configSetCommand, 'config get': configGetCommand, }, });
my-app config set theme dark runs configSetCommand with the args ['theme', 'dark']my-app config --help lists only the config commandsSet defaultCommand to run a command when none is given:
const config = defineConfig({ meta: { name: 'my-app', version: '1.0.0' }, commands: { start: startCommand, build: buildCommand }, defaultCommand: startCommand, });
my-app runs startCommandmy-app build runs buildCommandmy-app --help still shows the help messagedefineOptions takes a Zod object schema and an optional map from short alias to schema key. The .describe() text becomes the option's help line, and .default() is shown in help.
const options = defineOptions( z.object({ port: z.coerce.number().min(1).max(65535).default(3000).describe('Port to listen on'), files: z.array(z.string()).describe('Input files'), tags: z.array(z.string()).optional().describe('Tags to apply'), }), { p: 'port', f: 'files' }, );
Everything from the command line arrives as a string, so use z.coerce for numbers and other non-string types.
A single value for an array field is wrapped automatically, so both of these produce ['a.txt']:
--files a.txt
--files a.txt --files b.txt # ['a.txt', 'b.txt']Positional arguments are validated by a single schema that receives the whole array. Use z.tuple for a fixed shape and z.array for a variable number:
const copy = defineCommand({ description: 'Copy a file', args: z.tuple([z.string().describe('Source file'), z.string().describe('Destination file')]), options: defineOptions(z.object({ verbose: z.boolean().default(false) }), { v: 'verbose' }), action: async (options, args) => { const [source, destination] = args; // [string, string] console.log(`Copying ${source} to ${destination}`); }, });
| Form | Example | Result |
|---|---|---|
| Long flag | --verbose |
verbose: true |
| Long flag with value | --port 3000, --port=3000 |
port: '3000' |
| Short flag | -v, -p 3000 |
resolved through the alias map |
| Clustered short flags | -abc |
a: true, b: true, c: true |
| Kebab-case | --max-retries |
matches the schema key maxRetries |
| Repeated flag | --file a.txt --file b.txt |
file: ['a.txt', 'b.txt'] |
A flag whose next argument starts with -, or which is last, becomes true.
<cli> --help prints the command list, <cli> <command> --help prints that command's options.<cli> --version prints meta.version. It is only handled when no command is given and meta.version is set.Both paths print to stdout and call process.exit(0). Keep that in mind when calling processConfig from tests.
processConfig validates and returns โ it never invokes your action. That keeps parsing and execution separate, so commands stay testable.
import { z } from 'zod'; import { processConfig, ZodlineError } from 'zodline'; try { const result = processConfig(config, process.argv.slice(2)); await result.command.action(result.options, result.args); } catch (error) { if (error instanceof ZodlineError) { // Unknown command, unknown option, or no command specified console.error(error.message); } else if (error instanceof z.ZodError) { // An option failed schema validation console.error(z.prettifyError(error)); } else { throw error; } process.exit(1); }
Positional argument failures are currently thrown as a plain Error prefixed with Argument validation failed:.
| Export | Description |
|---|---|
defineOptions(schema, aliases?) |
Pairs a Zod object schema with an optional short-alias map. |
defineCommand(definition) |
Defines a command from a description, examples, options, args and action. |
defineConfig(config) |
Defines the CLI from meta, commands and an optional defaultCommand. |
processConfig(config, argv) |
Parses and validates argv, returning { command, options, args }. |
ZodlineError |
Thrown for unknown commands and unknown options. |
See the API reference for full signatures and types.
| zodline | commander | yargs | |
|---|---|---|---|
| Validation | Zod schemas | custom parser functions | built-in coercions |
| Option types | inferred from the schema | manual type annotations | inferred from the builder chain |
| Runtime dependencies | 0 | 0 | 6 |
| Module format | ESM only | CJS and ESM | CJS and ESM |
| Parsing and execution | separate | coupled | coupled |
Dependency counts as of commander 15 and yargs 18. Both cover more surface than zodline โ nested subcommands, shell completion, i18n. zodline is deliberately smaller and leans on Zod for everything it can.
npm install
npm test
npm run build
npm run lintBug reports and feature requests are welcome in the issue tracker.
zodline was previously published as @robingenz/zli. To migrate:
Replace the dependency:
npm uninstall @robingenz/zli npm install zodline
Update import specifiers:
- import { ... } from '@robingenz/zli'; + import { ... } from 'zodline';
Rename the ZliError export to ZodlineError (the public API is otherwise unchanged):
- import { ZliError } from '@robingenz/zli'; + import { ZodlineError } from 'zodline';
See CHANGELOG.md.
See LICENSE.