Skip to content

Read your tool's typed configuration

Get your config out of the App

App stores configuration type-erased, so you name the type on the way out:

async fn run(&self, app: App) -> miette::Result<()> {
    let Some(config) = app.typed_config::<MySettings>() else {
        return Err(miette::miette!("mytool: no typed configuration wired"));
    };
    let settings = config.get();
    println!("talking to {}", settings.host);
    Ok(())
}

typed_config::<C>() returns Option<Arc<Config<C>>>, and the returned Arc shares the same allocation as the one inside the App — the downcast is a single Any round-trip, cheap enough to call at the top of every command body without caching it anywhere.

Choose between typed_config and config_as

let config = app.config_as::<MySettings>();   // panics if the type does not match
Use When
typed_config::<C>() Anywhere the config might not be wired, or C might not be the tool's type. Returns None.
config_as::<C>() Only from the same crate that wired the config at startup. Panics.

config_as panics with a message naming the type you asked for, and #[track_caller] reports your line rather than one inside rtb-app:

App::config_as::<mytool::Settings>() — no matching typed config wired
(did `Application::builder().config(...)` get called with the right type?)

A library crate that might be used by more than one tool should always use typed_config — the panic is only reasonable when you personally know the config was wired.

Handle "no typed config" gracefully

A tool that has not typed its configuration yet has a Config<()> stored, so typed_config::<C>() returns None for every C except (). Commands meant to work in both situations should fall back rather than fail:

let host = app
    .typed_config::<MySettings>()
    .map(|c| c.get().host.clone())
    .unwrap_or_else(|| "localhost".to_string());

Why config_schema() is None when typed_config() is Some

This combination catches people out. It means the config was stored, but the schema-and-render bundle was never attached.

App::new erases a Config<C> and nothing else. It cannot build the bundle, because its bound on C is only DeserializeOwned while generating a schema needs JsonSchema and rendering a value needs Serialize. The bundle arrives separately, through App::with_typed_config — which is what rtb_cli::Application::builder().config(...) and TestAppBuilder::config(...) both call for you.

So:

How the App was built typed_config::<C>() config_schema() / config_value()
Application::builder().config(cfg) Some Some
TestAppBuilder::…config_value(c) Some Some
App::new(…, cfg, …) directly Some None
nothing wired None None

If you are building an App by hand and config show comes back empty, that last row is why.

Read the merged config without naming the type

Generic code — anything that has to work across tools — reads JSON instead:

if let Some(value) = app.config_value() {
    println!("{}", serde_json::to_string_pretty(&value)?);
}
if let Some(schema) = app.config_schema() {
    // schema is a &serde_json::Value
}

This is the path rtb-cli's config show / get / schema / validate takes, and it is the reason C never has to appear in rtb-cli's signatures.