Search
Search documentation
Composability
Updated Oct 23, 2026
Inputs and outputs in fireargs are serde structs, so anything serde and
schemars can compose works here too — newtypes, #[serde(flatten)],
generic structs, nested structs. Define common parameters once and reuse
them across every command that needs them, including the per-field CLI
config you attached with the fireargs meta.
Reusable fields
A newtype is a field you can drop into any struct. Its doc comment, its
schemars attributes and its fireargs meta travel with it, since fireargs
inlines its schema into each property, and its Default gives the default
wherever the field is #[serde(default)] — derive Serialize too, so
schemars can write that default into the schema and --help:
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// Page number
#[derive(Clone, Copy, Deserialize, Serialize, JsonSchema)]
#[schemars(extend("fireargs" = {"kind": "option", "short": "p"}))]
struct Page(#[schemars(range(min = 1))] u32);
impl Default for Page {
fn default() -> Self {
Page(1)
}
}
/// Page size
#[derive(Clone, Copy, Deserialize, Serialize, JsonSchema)]
#[schemars(extend("fireargs" = {"kind": "option", "short": "l"}))]
struct Limit(#[schemars(range(min = 1, max = 100))] u32);
impl Default for Limit {
fn default() -> Self {
Limit(20)
}
}A use site can still choose another default with
#[serde(default = "...")]. The handler reads the value as input.limit.0.
Reusable parameter blocks
Group fields into a struct and mix it into each command's input with
#[serde(flatten)]:
#[derive(Deserialize, JsonSchema)]
struct Pagination {
#[serde(default)]
page: Page,
#[serde(default)]
limit: Limit,
}
#[derive(Deserialize, JsonSchema)]
struct Verbosity {
/// Print more
#[serde(default)]
#[schemars(extend("fireargs" = {"kind": "option", "short": "v"}))]
verbose: bool,
}The meta you attached lives on the field, so when you reuse Pagination in
another command, --page / -p still come along for free.
Composing into a command
use fireargs::{self as f, Command, CommandConfig, Error};
#[derive(Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
enum Role {
Admin,
User,
}
#[derive(Serialize, JsonSchema)]
struct User {
id: String,
name: String,
}
#[derive(Deserialize, JsonSchema)]
struct UsersInput {
#[serde(flatten)]
pagination: Pagination,
#[serde(flatten)]
verbosity: Verbosity,
role: Option<Role>,
}
#[derive(Serialize, JsonSchema)]
struct UsersOutput {
users: Vec<User>,
total: u32,
}
fn create_list_users() -> Command {
f::command(CommandConfig {
description: Some("List users".into()),
..Default::default()
})
.input::<UsersInput>()
.output::<UsersOutput>()
.handler(|_, _| async move { Ok::<_, Error>(UsersOutput { users: vec![], total: 0 }) })
}A flattened block's fields take its place among the struct's own:
UsersInput declares page and limit, then verbose, then role. That
order is the --help order, and for positionals the order they are read in
— keep blocks with positionals in a deliberate place.
serde does not combine #[serde(flatten)] with
#[serde(deny_unknown_fields)]; a struct that flattens a block ignores
unknown --json keys.
Reusable output blocks
Output structs compose the same way. A common pattern is a generic "paginated response" struct:
#[derive(Serialize, JsonSchema)]
struct Paginated<T> {
items: Vec<T>,
total: u32,
page: u32,
limit: u32,
}
#[derive(Serialize, JsonSchema)]
struct Post {
id: String,
title: String,
}
fn create_list_posts() -> Command {
f::command(CommandConfig {
description: Some("List posts".into()),
..Default::default()
})
.input::<Pagination>()
.output::<Paginated<Post>>()
.handler(|input, _| async move {
let (page, limit) = (input.page.0, input.limit.0);
Ok::<_, Error>(Paginated { items: vec![], total: 0, page, limit })
})
}Every Paginated<...> output ends up with identical pagination metadata in
its --llms JSON Schema — same source of truth. The item struct is inlined
in place.
Shared handler code
Each command keeps its own small handler with concrete types; shared scaffolding goes into plain functions the handlers call:
fn paginate<T>(items: Vec<T>, pagination: &Pagination) -> Paginated<T> {
let (page, limit) = (pagination.page.0, pagination.limit.0);
let total = items.len() as u32;
let start = ((page - 1) * limit) as usize;
let items = items.into_iter().skip(start).take(limit as usize).collect();
Paginated { items, total, page, limit }
}
fn create_list_members() -> Command {
f::command(CommandConfig {
description: Some("List members".into()),
..Default::default()
})
.input::<UsersInput>()
.output::<Paginated<User>>()
.handler(|input, _| async move {
let users = vec![User { id: "1".into(), name: "Ada".into() }];
Ok::<_, Error>(paginate(users, &input.pagination))
})
}The helper hides the boilerplate; the commands only specify what genuinely differs.
Programs as another composition layer
Beyond struct composition, f::program(...) composes commands themselves
into trees. Mounting the same leaf under different router keys is a valid
form of reuse:
let list_users = create_list_users();
let cli = f::program(ProgramConfig {
name: Some("myapp".into()),
..Default::default()
})
.router([("users", list_users.clone().into()), ("members", list_users.into())]);Both invocation paths route to the same handler. See
Programs for the dispatch and --llms flattening
details.
What carries through
When you reuse a field or a block, every per-field detail rides along:
- serde's and schemars' own metadata — doc comments, defaults,
validation attributes,
rename, enum choices. - fireargs meta —
#[schemars(extend("fireargs" = {...}))](name, short, env, conflicts, implies, hidden, preset, helpGroup, defaultDescription).
Both surface in clap's --help and in the --llms manifest exactly as if
you'd written the fields inline. A field's .parse(...) is the exception:
it is registered on each command's builder by key. Compose freely; nothing
leaks or duplicates.
Created with ❤ and Livemark