Composability

Updated Apr 30, 2026

Inputs and outputs in fireargs are zod object schemas, so anything zod can compose works here too — .extend(), .merge(), shape spread, discriminated unions. Define common parameters once and reuse them across every command that needs them, including the per-field commander config you attached via f.argument() / f.option().

Reusable parameter blocks

Build a zod object once, then mix it into each command's input:

import { f } from "fireargs"
import { z } from "zod"

const paginationParams = z.object({
  page: f
    .option({ short: "p" })
    .schema(z.coerce.number().int().min(1).default(1)),
  limit: f
    .option({ short: "l" })
    .schema(z.coerce.number().int().min(1).max(100).default(20)),
})

const verbosityParams = z.object({
  verbose: f.option({ short: "v" }).boolean(),
  quiet: f.option({ short: "q" }).boolean(),
})

The metadata you attached with f.option(...) lives on the schema, so when you reuse paginationParams in another command, --page / -p still come along for free.

Composing into a command

Three equivalent shapes — pick whichever reads cleanly for the call site.

Shape spread (most flexible)

const listUsers = f
  .command({ name: "users", description: "List users" })
  .input(
    z.object({
      ...paginationParams.shape,
      ...verbosityParams.shape,
      role: z.enum(["admin", "user"]).optional(),
    }),
  )
  .output(z.object({ users: z.array(userSchema), total: z.number() }))
  .handler(input => ({ users: [], total: 0 }))

.extend(...) from a base

const baseParams = z.object({
  ...paginationParams.shape,
  ...verbosityParams.shape,
})

const listPosts = f
  .command({ name: "posts" })
  .input(baseParams.extend({ author: z.string().optional() }))
  .output(z.object({ posts: z.array(postSchema), total: z.number() }))
  .handler(input => ({ posts: [], total: 0 }))

.merge(...) of two objects

const filterParams = z.object({ since: z.iso.datetime().optional() })

const listEvents = f
  .command({ name: "events" })
  .input(paginationParams.merge(filterParams))
  .output(z.object({ events: z.array(eventSchema) }))
  .handler(input => ({ events: [] }))

Reusable output blocks

Output schemas compose the same way. A common pattern is a generic "paginated response" wrapper:

function paginated<T extends z.ZodType>(item: T) {
  return z.object({
    items: z.array(item),
    total: z.number(),
    page: z.number(),
    limit: z.number(),
  })
}

const userOutput = paginated(z.object({ id: z.string(), name: z.string() }))
const postOutput = paginated(z.object({ id: z.string(), title: z.string() }))

Both userOutput and postOutput end up with identical pagination metadata in their --llms JSON Schemas — same source of truth.

Command factories

If many commands share more than just inputs — e.g. they all return a paginated list with the same handler scaffolding — encapsulate the pattern in a factory that returns a partially-built command builder:

function listCommand<T extends z.ZodObject>(
  config: Parameters<typeof f.command>[0],
  itemSchema: T,
) {
  return f
    .command(config)
    .input(
      z.object({
        ...paginationParams.shape,
        ...verbosityParams.shape,
      }),
    )
    .output(paginated(itemSchema))
}

const listUsers = listCommand(
  { name: "users", description: "List users" },
  z.object({ id: z.string(), name: z.string() }),
).handler(input => ({
  items: [],
  total: 0,
  page: input.page,
  limit: input.limit,
}))

const listPosts = listCommand(
  { name: "posts" },
  z.object({ id: z.string(), title: z.string() }),
).handler(input => ({
  items: [],
  total: 0,
  page: input.page,
  limit: input.limit,
}))

The factory hides the boilerplate; the call sites only specify what genuinely differs.

Programs as another composition layer

Beyond schema composition, f.program(...) composes commands themselves into trees. Mounting the same leaf under different program keys is a valid form of reuse:

const cli = f.program({ name: "myapp" }).commands({
  users: listUsers,
  members: listUsers,
})

Both invocation paths route to the same handler. See Programs for the dispatch and --llms flattening details.

What carries through

When you reuse a fragment, every per-field detail rides along:

  • Zod's own metadata.describe(), .default(), .optional(), .enum(), .coerce, validation rules.
  • fireargs metadataf.argument() / f.option() config (short, env, conflicts, hidden, preset, helpGroup, defaultDescription).

Both surface in commander's --help and in the --llms manifest exactly as if you'd written the fields inline. Compose freely; nothing leaks or duplicates.

Created with and Livemark