Skip to content

Why the config is type-erased

The problem

Commands need their tool's configuration, and that configuration is a different type in every tool. The direct expression of that is a generic parameter:

pub struct App<C: AppConfig> { /* … */ }

pub trait Command<C: AppConfig> {
    async fn run(&self, app: App<C>) -> miette::Result<()>;
}

This does not work, and the reason is not aesthetic. BUILTIN_COMMANDS stores Box<dyn Command>, which requires Command to be object-safe. A trait with a type parameter can still be made into an object, but only once that parameter is fixed — so the slice would have to be Box<dyn Command<MySettings>>, a different type per tool, and rtb-app cannot declare a slice whose element type depends on a crate that does not exist yet.

Every alternative that keeps App generic pushes the generic parameter through the Command trait, the registry, and the rtb-cli dispatch path, and ends at the same wall.

What it does instead

The config is stored erased:

pub type ErasedConfig = Arc<dyn Any + Send + Sync>;

and recovered by naming the type on the way out:

let settings = app.typed_config::<MySettings>();   // Option<Arc<Config<MySettings>>>

App stays a concrete type. Command stays object-safe. BUILTIN_COMMANDS stays a single slice every tool shares. The tool's config type appears only where the tool's own code names it.

Why Arc<dyn Any> and not Arc<dyn SomeTrait>

Erasing behind a bespoke trait would also work for storage, but it loses something specific: Arc::downcast on an Arc<dyn Any + Send + Sync> produces an Arc<Config<C>> sharing the same allocation. There is no copy and no second refcount.

That is what makes the round trip honest. App::clone() followed by typed_config::<C>() on both copies yields two Arcs for which Arc::ptr_eq holds — they are the same object, not two views of equal data. A tool can clone an App into a dozen spawned tasks and every one of them reads the same configuration.

The two seams this leaves

Type erasure moves a compile-time guarantee to runtime, and the crate is upfront about where.

The type must match, and a mismatch is a runtime outcome. typed_config::<C>() returns None when C is not the wired type; config_as::<C>() panics. Neither can be caught by the compiler, so config_as carries #[track_caller] and a panic message naming the requested type, so the failure at least diagnoses itself:

App::config_as::<mytool::Settings>() — no matching typed config wired

Schema and rendering need bounds storage does not. Storing a config needs only DeserializeOwned. Producing a JSON Schema needs JsonSchema, and rendering the merged value needs Serialize. Rather than raise App::new's bound and force every tool to derive both, the schema and render closures live in a separate TypedConfigOps bundle, built where C is still in scope and attached alongside the erased value.

That is why App::config_schema() and App::config_value() return Option. It is also why they can return None while typed_config::<C>() returns Some — the config was stored through App::new, which cannot build the bundle. It is a real seam, not a defensive Option.

What this buys rtb-cli

rtb-cli implements config show / get / schema / validate for every tool built on the toolkit without any of its signatures mentioning a config type. It reads app.config_schema() and app.config_value(), both plain serde_json::Value, and the tool's C is captured inside a closure it never has to name. Generic behaviour over an unknown type, with the erasure confined to one small module.