Skip to content
zodline
Esc
navigateopen⌘Jpreview
On this page

Introduction

A type-safe CLI parser built on Zod. Declare commands, options and positional args as schemas and get a fully-typed, validated result.

zodline turns Zod schemas into a command-line interface. You declare what your CLI accepts — commands, options, positional arguments — and zodline parses process.argv, validates it against those schemas, and hands back a fully-typed result. The types come from the schemas you already wrote; there are no generics to thread through by hand.

It has zero runtime dependencies — Zod is the only peer dependency — and ships as ESM only.

Why zodline

Types from schemas

options and args are inferred from your Zod schemas. Rename a field and every call site fails to compile.

Declarative

A command is an object: a description, an options schema, an args schema, an action. No builder chains.

Validation included

Coercion, defaults, refinements, unions — anything Zod can express, your CLI can accept.

Strict by default

An unknown flag is an error, not a silently ignored key. Typos surface immediately.

Zero dependencies

Nothing but Zod, which you already have. Nothing to audit, nothing to bloat your install.

Parse, then run

processConfig validates and returns. It never invokes your action, so commands stay testable.

A first look

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' },
  ),
  action: async (options) => {
    const greeting = `Hello, ${options.name}!`;
    console.log(options.loud ? greeting.toUpperCase() : greeting);
  },
});

const config = defineConfig({
  meta: { name: 'my-cli', version: '1.0.0' },
  commands: { greet },
});

const result = processConfig(config, process.argv.slice(2));
await result.command.action(result.options, result.args);
my-cli greet --name World --loud
# HELLO, WORLD!

options above is typed as { name: string; loud: boolean } — inferred from the schema, not declared twice.

Parsing and execution are separate

processConfig returns { command, options, args }. It does not call command.action. Running the command is one line you write yourself, which means you can parse an argv array in a test and assert on the result without executing anything.

const result = processConfig(config, ['greet', '--name', 'World']);

expect(result.options).toEqual({ name: 'World', loud: false });

See Parsing vs. execution for the full rationale.

Where to next

Last updated on August 6, 2026

Was this page helpful?