Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Configuration Management

Configuration is usually a combination of:

  • Defaults (checked into source control)
  • Environment variables (per-deployment)
  • Optional config files (per-environment or per-user)

Suji has no if statements, so use match for branching and guard clauses for early exits.

Loading

Layer the sources: start from the defaults, overlay the config file, then let environment variables win. map::merge(other) mutates the receiver and returns nil, so merge into the accumulating config.

import std:dotenv
import std:env
import std:io
import std:json
import std:os
import std:println

# Sample .env and config file
env_path = `mktemp`
f = io:open(env_path, true, true)
f::write("APP_PORT=9000\n")
f::close()

config_path = `mktemp`
g = io:open(config_path, true, true)
g::write("""{"log_level": "debug", "port": 8081}""")
g::close()

exists = |p| `test -f "${p}" && echo yes || echo no` == "yes"

load_json = |p| {
    file = io:open(p)
    parsed = json:parse(file::read_all())
    file::close()
    parsed
}

dotenv:load(env_path)

config = { port: 8080, log_level: "info" }

# 1. Overlay the config file, if there is one
match { exists(config_path) => { config::merge(load_json(config_path)) } }

# 2. Environment variables win
env_port = env:var::get("APP_PORT", nil)
match { env_port != nil => { config["port"] = env_port::to_number() } }

println("port=${config:port}")            # port=9000
println("log_level=${config:log_level}")  # log_level=debug

os:rm(env_path)
os:rm(config_path)

dotenv:load(path) returns the map it loaded and also writes the values into env:var, so later lookups see them.

Environments

import std:env
import std:println

env_name = env:var::get("APP_ENV", "development")

settings = match env_name {
    "production" => { "debug": false, "workers": 8 },
    "staging" => { "debug": false, "workers": 2 },
    _ => { "debug": true, "workers": 1 },
}

println("${env_name}: debug=${settings:debug} workers=${settings:workers}")

Output when APP_ENV is unset:

development: debug=true workers=1

Note the quoted keys: a { ... } with bare identifier keys as a match-arm body is parsed as a block, so quote the keys (or wrap the map in parentheses).

Validation

There is no exception mechanism, so validation returns a (ok, message) tuple that the caller destructures:

import std:println

validate_config = |config| {
    match {
        !config::contains("port") => return (false, "port is required"),
        !config:port::is_number() => return (false, "port must be a number"),
        config:port < 1 => return (false, "port must be >= 1"),
        config:port > 65535 => return (false, "port must be <= 65535"),
    }

    return (true, nil)
}

ok, message = validate_config({ port: 8080 })
println("${ok} ${message}")  # true nil

ok2, message2 = validate_config({ port: "8080" })
println("${ok2} ${message2}")  # false port must be a number

Since a runtime error terminates the process, check ::contains before reading a key and ::is_number() before comparing — the order of the arms matters.

Merging

map::merge mutates its receiver. When you need the original left untouched, copy first:

import std:println

merge = |base, override| {
    out = base       # maps are copied on assignment
    out::merge(override)
    out
}

defaults = { port: 8080, log_level: "info" }
overrides = { port: 9000 }

merged = merge(defaults, overrides)
println(merged)    # {port: 9000, log_level: info}
println(defaults)  # {port: 8080, log_level: info}

For a deep merge, recurse when both sides hold a map:

import std:println

deep_merge = |base, override| {
    out = base
    loop through override with key, value {
        old = out::get(key, nil)
        out[key] = match {
            old::is_map() && value::is_map() => deep_merge(old, value),
            _ => value,
        }
    }
    out
}

result = deep_merge(
    { server: { host: "localhost", port: 8080 }, debug: false },
    { server: { port: 9000 } },
)

println(result)  # {server: {host: localhost, port: 9000}, debug: false}

Type-Safe Access

Configuration values that arrive from files or the environment are often strings. Check the type before using them, and fall back to a default:

import std:println

get_port = |config| {
    port = config::get("port", nil)
    match {
        port::is_number() => port,
        port::is_string() && port ~ /^\d+$/ => port::to_number(),
        _ => 8080,
    }
}

println(get_port({ port: 3000 }))      # 3000
println(get_port({ port: "3000" }))    # 3000
println(get_port({ port: "http" }))    # 8080
println(get_port({}))                  # 8080

::to_number() on a non-numeric string is a runtime error, which is why the regex guard comes first.

See Also