Skip to main content
Type safety isn’t just for humans—agents building CLIs with incur get immediate feedback when they pass the wrong argument type or return the wrong shape. Schemas flow through generics so run callbacks, output, and cta commands are all fully inferred with zero manual annotations.

Inferred Arguments and Options

Types flow from Zod schemas to the run callback:
import { Cli, z } from 'incur'

const cli = Cli.create('my-cli', { description: 'My CLI' })
  .command('greet', {
    args: z.object({ 
      name: z.string() 
    }),
    options: z.object({ 
      loud: z.boolean().default(false) 
    }),
    run(c) {
      // c.args.name is inferred as string
      // c.options.loud is inferred as boolean
      const message = `hello ${c.args.name}`
      return { message: c.options.loud ? message.toUpperCase() : message }
    },
  })

cli.serve()
No type annotations needed—TypeScript infers everything from the schemas.

Inferred Output Schema

The output schema validates return values:
import { Cli, z } from 'incur'

Cli.create('my-cli', { description: 'My CLI' })
  .command('status', {
    output: z.object({
      clean: z.boolean(),
      branch: z.string(),
    }),
    run() {
      // Return type is validated against output schema
      return { clean: true, branch: 'main' }
      
      // ❌ This would be a type error:
      // return { clean: 'yes', branch: 'main' }
    },
  })
  .serve()

Inferred CTA Commands

Command names in CTAs are type-checked:
import { Cli, z } from 'incur'

const cli = Cli.create('my-cli', { description: 'My CLI' })
  .command('list', {
    run(c) {
      return c.ok(
        { items: [] },
        {
          cta: {
            commands: [
              'create',  // ✓ Valid command name
              'status',  // ❌ Type error: command doesn't exist
            ],
          },
        },
      )
    },
  })
  .command('create', {
    run() {
      return { created: true }
    },
  })

cli.serve()

Inferred CTA Arguments

Arguments in CTA command objects are type-checked:
import { Cli, z } from 'incur'

const cli = Cli.create('my-cli', { description: 'My CLI' })
  .command('get', {
    args: z.object({ 
      id: z.number() 
    }),
    run(c) {
      return { id: c.args.id, title: 'Task' }
    },
  })
  .command('list', {
    run(c) {
      return c.ok(
        { items: [{ id: 1 }] },
        {
          cta: {
            commands: [
              { 
                command: 'get', 
                args: { id: 1 }  // ✓ Valid: id is a number
              },
              { 
                command: 'get', 
                args: { id: 'one' }  // ❌ Type error: id must be number
              },
            ],
          },
        },
      )
    },
  })

cli.serve()

Inferred Environment Variables

Environment variable types flow to the context:
import { Cli, z } from 'incur'

Cli.create('my-cli', {
  description: 'My CLI',
  env: z.object({
    API_KEY: z.string(),
    PORT: z.coerce.number().default(3000),
    DEBUG: z.boolean().default(false),
  }),
})
  .command('start', {
    run(c) {
      // c.env.API_KEY is string
      // c.env.PORT is number
      // c.env.DEBUG is boolean
      return { 
        port: c.env.PORT, 
        debug: c.env.DEBUG 
      }
    },
  })
  .serve()

Inferred Middleware Variables

Variables set by middleware are type-checked:
import { Cli, middleware, z } from 'incur'

type User = { id: string; name: string }

const cli = Cli.create('my-cli', {
  description: 'My CLI',
  vars: z.object({
    user: z.custom<User>(),
    requestId: z.string(),
  }),
})

const authMiddleware = middleware<typeof cli.vars>((c, next) => {
  // c.set() is typed
  c.set('user', { id: 'u_123', name: 'Alice' })  // ✓
  c.set('requestId', crypto.randomUUID())  // ✓
  
  // ❌ Type error: invalid key
  // c.set('invalid', 'value')
  
  // ❌ Type error: wrong type for user
  // c.set('user', 'not-a-user-object')
  
  return next()
})

cli.use(authMiddleware)

cli.command('whoami', {
  run(c) {
    // c.var.user is typed as User
    // c.var.requestId is typed as string
    return { 
      name: c.var.user.name,
      requestId: c.var.requestId 
    }
  },
})

cli.serve()

Const Generics

incur uses const generic parameters to preserve literal types:
import { Cli, z } from 'incur'

const cli = Cli.create('my-cli', { description: 'My CLI' })
  .command('deploy', {
    args: z.object({ 
      env: z.enum(['staging', 'production']) 
    }),
    run(c) {
      // c.args.env is inferred as 'staging' | 'production'
      // not just string
      return { deployed: c.args.env }
    },
  })
This enables precise type-checking in CTAs:
cli.command('list', {
  run(c) {
    return c.ok(
      { items: [] },
      {
        cta: {
          commands: [
            { 
              command: 'deploy', 
              args: { env: 'staging' }  // ✓ Valid enum value
            },
            { 
              command: 'deploy', 
              args: { env: 'dev' }  // ❌ Type error: not in enum
            },
          ],
        },
      },
    )
  },
})

Output Type Inference

Use z.output<> to get the type after transforms and defaults:
import { Cli, z } from 'incur'

const argsSchema = z.object({ 
  count: z.coerce.number().default(10) 
})

type Args = z.output<typeof argsSchema>
// Args = { count: number }

Cli.create('my-cli', { description: 'My CLI' })
  .command('list', {
    args: argsSchema,
    run(c) {
      // c.args matches z.output<argsSchema>
      // count is guaranteed to be a number
      const items = Array.from({ length: c.args.count })
      return { items }
    },
  })
  .serve()

Chained Type Inference

Types flow through .command() chains:
import { Cli, z } from 'incur'

const cli = Cli.create('my-cli', { description: 'My CLI' })
  .command('create', {
    args: z.object({ name: z.string() }),
    run(c) {
      return { id: 1, name: c.args.name }
    },
  })
  .command('list', {
    run(c) {
      return c.ok(
        { items: [] },
        {
          cta: {
            commands: [
              // Command 'create' is available with typed args
              { command: 'create', args: { name: 'New Item' } },
            ],
          },
        },
      )
    },
  })

// The CLI type now includes both commands
type Commands = typeof cli

Mounted CLI Type Inference

Types flow through mounted sub-CLIs:
import { Cli, z } from 'incur'

const pr = Cli.create('pr', { description: 'PR commands' })
  .command('list', {
    options: z.object({ 
      state: z.enum(['open', 'closed']).default('open') 
    }),
    run(c) {
      return { prs: [], state: c.options.state }
    },
  })

const cli = Cli.create('my-cli', { description: 'My CLI' })
  .command(pr)  // Mount pr group
  .command('status', {
    run(c) {
      return c.ok(
        { clean: true },
        {
          cta: {
            commands: [
              // 'pr list' is available with typed options
              { 
                command: 'pr list', 
                options: { state: 'closed' } 
              },
            ],
          },
        },
      )
    },
  })

cli.serve()

Generic Type Flow

How types flow through incur:
Zod Schema → Generic Parameter → Output Type → Context → Return Type

z.object({     const args      z.output<args>   c.args    return value
  name: z.string()                              .name
})                                              : string

Type Inference Path

  1. Schema definition — Zod schema defines the shape
  2. Generic captureconst generic preserves literal types
  3. Output extractionz.output<> gets runtime type
  4. Context typing — Type flows to c.args, c.options, etc.
  5. Validation — Return value is checked against output schema

Type Tests

incur includes type tests to ensure inference works correctly. From src/Cli.test-d.ts:
import { expectTypeOf } from 'vitest'
import { Cli, z } from 'incur'

const cli = Cli.create('test', {})
  .command('greet', {
    args: z.object({ name: z.string() }),
    options: z.object({ loud: z.boolean().default(false) }),
    run(c) {
      expectTypeOf(c.args.name).toEqualTypeOf<string>()
      expectTypeOf(c.options.loud).toEqualTypeOf<boolean>()
      return { message: 'hello' }
    },
  })

Zero Annotations

Notice how the examples above have zero type annotations in the run callbacks. Everything is inferred:
run(c) {
  // No type annotations needed!
  // c.args is fully typed
  // c.options is fully typed
  // c.env is fully typed
  // c.var is fully typed
  // Return type is validated
}
This is critical for agents building CLIs—they can focus on logic without wrestling with TypeScript annotations.

Type-Driven Development

Define schemas first, then implementation:
import { Cli, z } from 'incur'

// 1. Define schemas
const deployArgs = z.object({
  env: z.enum(['staging', 'production']),
})

const deployOptions = z.object({
  force: z.boolean().optional(),
})

const deployOutput = z.object({
  url: z.string(),
  duration: z.number(),
})

// 2. Create command (types flow automatically)
Cli.create('my-cli', { description: 'My CLI' })
  .command('deploy', {
    args: deployArgs,
    options: deployOptions,
    output: deployOutput,
    run(c) {
      // c.args.env: 'staging' | 'production'
      // c.options.force: boolean | undefined
      // Return type must match deployOutput
      return {
        url: `https://${c.args.env}.example.com`,
        duration: 3.2,
      }
    },
  })
  .serve()
Schemas are the source of truth. Types flow from schemas to callbacks automatically—no manual annotations needed.

Build docs developers (and LLMs) love