API Design for Config Override

I keep encountering this pattern which I have struggled to find a good solution.

  • There is a global config with non-nullable fields
  • Each item can override some per-item config fields

Example: RSS Reader

struct App {
	config: Config,    // global config
	feeds: Vec<Feed>,  // per-item config
}

struct Config {
	max_retry: u8,
	browser_agent: String,
	fetch_interval: std::time::Duration,
}

struct OptionalConfig {
	max_retry: Option<u8>,
	browser_agent: Option<String>,
	fetch_interval: Option<std::time::Duration>,
}

struct Feed {
	source: Source,
	config: OptionalConfig,
}

Here OptionalConfig feels too verbose.

Example: Theme

A nested config is even more complex. All intermediate and leaf attributes should be Option.

pub struct Theme {
    pub title: LabelStyle,
    pub subtitle: LabelStyle,
	...
}

pub struct LabelStyle {
    pub family: FamilyCow,
    pub size: Logical<f32>,
    pub line_height: Option<Logical<f32>>,
    pub color: Color,
    pub weight: u16,
}


struct Grid {
	theme: Theme,
	children: Vec<Box<Grid>>,
}

expands to

pub struct ThemeOverride {
    pub title: Option<LabelStyleOverride>,
    pub subtitle: Option<LabelStyleOverride>,
	...
}

pub struct LabelStyleOverride {
    pub family: Option<FamilyCow>,
    pub size: Option<Logical<f32>>,
    pub line_height: Option<Option<Logical<f32>>>,
    pub color: Option<Color>,
    pub weight: Option<u16>,
}

grid.theme = default_theme;
grid.children[0].theme = default_theme.override(theme_override);

This has many problems.

  • line_height: Option<Option<..>> is difficult to use and serialize. Also ambiguous when exposed through PyO3 or wasm-bindgen.
  • ThemeOverride is large even when there are only few fields to override.
  • Theme already uses more than 30 types, and adding override types for each is too much.
  • The API for grid.children[0].theme is bad; Updating grid.theme doesn't automatically update the child theme. Ideally the child item should only specify the difference, which will be computed by the library

Is there a better approach?

w/o knowing the details of your requirements, i'd suggest you make, in the RSS Reader example, the Feed's config field, an impl method instead. something like...

impl Feed {
    fn config(&self) -> Config {...}
    ...
}

the concrete implementation can then inherit the global Config, overrides whatever it needs, cache the result or not, and return the same Config type.

there's a crate literally named config, which implements hierarchical and layered configuration resolution.

if your types implement the serde data model, it's very easy to integrate the config crate. you can also just use config directly if you are ok to work with dynamically typed values, i.e. instead of direct field access my_config.some_field, you use a runtime checked call my_config.get::<FieldType>("field_name").

check out the examples for more demos.

A crate might well be better than roll-your-own, but here's a couple roll-your-own ideas I had.


You could use shared ownership (Arc<_>, Rc<_>) at select layers. Perhaps with some sort of interning other other bookkeeping to minimize duplicates. It would partially address the following (depending on how granular your Arc placement is), but perhaps not fully.

HashMap<SettingEnum, SettingValue> occured to me but it gives up a lot of type safety.

You can instead use a HashSet without sacrificing much type safety if you have a distinct type that ignores the values for the sake of equality and hashing; that would be amenable to storing deltas.

Example
pub enum Setting {
    LabelStyleSize(Logical<f32>),
    LabelStyleLineHeight(Option<Logical<f32>>),
    LabelStyleColor(Color),
    LabelStyleWeight(u16),
    // ...
}

pub struct SettingEntry {
    setting: Setting,
}
impl SettingEntry {
    fn id(&self) -> Discriminant<Setting> {
        std::mem::discriminant(&self.setting)
    }
}
impl Eq for SettingEntry {}
impl PartialEq for SettingEntry {
    fn eq(&self, rhs: &Self) -> bool {
        self.id() == rhs.id()
    }
}
impl std::hash::Hash for SettingEntry {
    fn hash<H: std::hash::Hasher>(&self, hasher: &mut H) {
        self.id().hash(hasher)
    }
}

Fetching values without a Setting in hand isn't the greatest, but can be encapsulated somewhat.

Example
// A little nicer with `hash_brown::{HashSet, Equivalent}`
impl Equivalent<SettingEntry> for Discriminant<Setting> {
    fn equivalent(&self, key: &SettingEntry) -> bool {
        key.id() == *self
    }
}
struct SettingOverride {
    overrides: HashSet<SettingEntry>,
}
impl SettingOverride {
    fn label_style_size(&self) -> Option<&Logical<f32>> {
        const KEY: Discriminant<Setting> = std::mem::discriminant(
            &Setting::LabelStyleSize(Logical(0.0))
        );
        match self.overrides.get(&KEY)? {
            SettingEntry { setting: Setting::LabelStyleSize(sz) } => Some(sz),
            _ => unreachable!(),
        }
    }

    fn override_of(&self, setting: &Setting) -> Option<&Setting> {
        let key = std::mem::discriminant(setting);
        self.overrides.get(&key).map(|se| &se.setting)
    }
}

Using TypeId and Box<dyn Any> instead of Discriminant<_> and an enum is similar in many ways.

Hello,

I might consider creating my own enum for the overrides:

enum Revise<T> {
    Preserve,
    Override(T)
}

pub struct ThemeOverride {
    pub title: Revise<LabelStyleOverride>,
    pub subtitle: Revise<LabelStyleOverride>,
	...
}

pub struct LabelStyleOverride {
    pub family: Revise<FamilyCow>,
    pub size: Revise<Logical<f32>>,
    pub line_height: Revise<Option<Logical<f32>>>,
    pub color: Revise<Color>,
    pub weight: Revise<u16>,
}

This might come with its own custom serialization, for example {} for Preserve and { "override": ... } for Override.