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
- Schema definition — Zod schema defines the shape
- Generic capture —
const generic preserves literal types
- Output extraction —
z.output<> gets runtime type
- Context typing — Type flows to
c.args, c.options, etc.
- 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.