Skip to content

Why there is no get_string("foo.bar")

Most configuration libraries hand you a bag of values and a set of accessors: get_string("http.host"), get_int("http.port"), a Sub("http") to scope into a section. rtb-config has none of that, and the omission is deliberate.

Config<C> is a generic container over a struct you declare and derive serde::Deserialize on. Configuration is reached by field access, like any other Rust value.

What the dynamic version costs

A string key is not checked by anything. get_string("http.hosst") compiles, runs, and returns an empty string or a default — at which point the tool connects to the wrong place, or to nowhere, with no error anywhere near the mistake. Rename a key in the config file and every call site that names it keeps compiling.

The type is not checked either. Asking for a string where the file holds an integer is a runtime question, answered differently by every library: some coerce, some return a zero value, some panic. In each case the failure surfaces where the value is used, which can be a long way from where it was loaded.

In a dynamically typed language that is a fair trade — there is no compiler to enlist. In Rust it is throwing away the language's main advantage.

What the typed version buys

Declaring the shape once means the compiler checks every use of it:

#[derive(Deserialize)]
struct MyConfig {
    host: String,
    port: u16,
    http: HttpSection,
}
  • A misspelled field is a compile error, at every call site, immediately.
  • A type mismatch is a compile error, not a surprise at runtime.
  • Renaming a field is a refactor the compiler drives, and cargo check finds every place that has to change.
  • Nested access is cfg.http.port, with completion and go-to-definition, rather than a string that no tool can follow.

Bad input still fails at runtime — that is unavoidable, since the file is written by a human after the binary was built — but it fails once, at load, with a message naming the key and the layer it came from. Not later, at the point of use, as a zero value.

What this costs you

Being honest about the other side of the trade:

  • The shape is fixed at compile time. A tool that must accept keys it does not know about — plugin configuration, arbitrary user-defined sections — cannot express that as named fields. The escape hatch is a HashMap<String, serde_json::Value> field, which is the dynamic bag again, scoped to where it is genuinely needed.
  • There is no generic inspection API. "Print every configured key and value" is not something the crate can do for you; it is serde::Serialize on your own struct.
  • Every consumer needs the type. A helper that works over "some configuration" has to be generic over C or take the concrete struct.

For a CLI or a service — the things this toolkit builds — the shape is known at compile time, and those costs do not bite.

Why the Go toolkit does it differently

The Go sibling of this crate exposes a dynamic Containable interface with GetString("foo.bar") accessors, and that is not an inconsistency anyone intends to fix. Go's reflection-based struct handling and its lack of generics at the time made the dynamic bag the natural idiom there; Rust's derive macros and monomorphisation make the typed container the natural idiom here.

The two toolkits aim at the same outcomes — layered precedence, an explicit reload, one error surface — and reach them through whatever each language does well. A port that carried the Go accessor API into Rust would have been faithful to the implementation and wrong about the point of it.

Next