Skip to main content

Configuration

Typed configuration with zero boilerplate: describe your config as an interface, bind it to a file with readConfig, and the compiler synthesizes an implementor that loads the file once and deserializes each value.

import "std/config.xi"

type TaxConfig = { percent: Number, rate: Integer }

interface AppConfig {
mapper projectName() -> String // reads the `projectName` key
mapper tax() -> TaxConfig // reads + decodes the `tax` key
mapper flags() -> Flags
}

module App { bind AppConfig -> readConfig("application.yaml") }
module Test { bind AppConfig -> readConfig("application-test.yaml") }

Inject it like any other dependency:

async entry (cfg: AppConfig, logger: Logger) main(args: String[]) -> Integer {
logger.info("project = " + cfg.projectName())
logger.info("tax = " + number_to_str(cfg.tax().percent) + "%")
return 0
}
# application.yaml
projectName: Ledger
tax:
percent: 20.0
rate: 3

readConfig<T> — read any file into a value

Beyond binding a whole interface, you can read a single file into a value with the generic readConfig<T> form. The format is chosen by extension — JSON, YAML, and XML are all supported:

import "std/config.xi"
type Tax = { percent: Number, rate: Integer }

let tax = readConfig<Tax>("tax.yaml") // or .json / .xml
let tax2 = readConfig<Tax>(path) // the path can be dynamic

Primitives and (nested) compounds decode automatically via the derived codec; a missing key is the zero value.

How it works

  • Each interface method name maps to a top-level config key (tax() → the tax key).
  • The return type drives deserialization: primitives (String, Number, Integer, Bool) are read directly; compound types are decoded via the derived JSON codec (nested objects supported).
  • The file is read once (a singleton) on first use.
  • YAML and JSON are both supported, chosen by the file extension.
  • A missing key yields the type's zero value (empty string, 0, false, all-zero compound) — it never crashes.

Live reload — ApplicationConfig

std/config ships an ApplicationConfig service (default impl FileApplicationConfig). Inject it and call watch(file, topic); a background watcher polls the file's mtime and publishes a ConfigChanged { file } event (via std/events) whenever it's edited — re-read the config in a listener to hot-reload:

import "std/config.xi"

class Reloader {
deps {}
listener onChange(e: ConfigChanged) on "config.changed" {
let cfg = readConfig<AppConfig>(e.file) // pick up the new values
...
}
}

async entry (cfg: ApplicationConfig) main(args: String[]) -> Integer {
cfg.watch("application.yaml", "config.changed")
let pump = Events.runAsync() // deliver events on a worker
...
}

Bind your own ApplicationConfig (an OS-native watcher, or a no-op in tests) to change the watching strategy — callers don't change.

Test configuration

In a test build (xi test), a bind inside module Test wins over module App, so tests transparently load a test config:

module App { bind AppConfig -> readConfig("application.yaml") }
module Test { bind AppConfig -> readConfig("application-test.yaml") }

See examples/config_demo.xi.

readConfig is recognized by the compiler only as a bind target — it is not a callable function.