A zero-dependency configuration loader for Zig, inspired by bunfig.
- 🔍 Multi-source loading - Local files, home directory, environment variables, defaults
- 🎯 Type-aware env vars - Automatic parsing of booleans, numbers, arrays, and JSON
- 🔗 Deep merging - Three strategies: replace, concat, and smart object array merging
- 🛡️ Circular reference detection - Prevents infinite loops during merge
- 📁 Multiple formats - JSON and Zig files (extensible)
- 🎨 Simple API - Clean, ergonomic interface
Add zonfig as a dependency in your build.zig:
const zonfig = b.dependency("zonfig", .{
.target = target,
.optimize = optimize,
});
exe.root_module.addImport("zonfig", zonfig.module("zonfig"));const std = @import("std");
const zonfig = @import("zonfig");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Load configuration
var config = try zonfig.loadConfig(allocator, .{
.name = "myapp",
});
defer config.deinit();
// Access values
const port = config.config.object.get("port").?.integer;
const debug = config.config.object.get("debug").?.bool;
std.debug.print("Server running on port {d}\n", .{port});
}Zonfig loads configuration from multiple sources with the following priority (highest to lowest):
- Environment variables (highest priority)
- Local project file (
./myapp.json,./config/myapp.json,./.config/myapp.json) - Home directory (
~/.config/myapp.json) - Defaults (provided in code)
Environment variables are automatically parsed with type awareness:
# Boolean values
export MYAPP_DEBUG=true # → bool
export MYAPP_VERBOSE=1 # → bool (true)
export MYAPP_QUIET=false # → bool
export MYAPP_COLORS=yes # → bool (true)
# Numbers
export MYAPP_PORT=3000 # → integer
export MYAPP_TIMEOUT=30.5 # → float
# Arrays (comma-separated)
export MYAPP_HOSTS=localhost,api.example.com,cdn.example.com # → array of strings
# JSON objects/arrays
export MYAPP_DATABASE='{"host":"localhost","port":5432}' # → object
export MYAPP_TAGS='["production","web"]' # → array
# Strings (default)
export MYAPP_NAME="My Application" # → stringEnvironment variable naming:
- Prefix: Uppercase version of config name (or custom
env_prefix) - Nested keys: Separated by underscores
- Hyphens: Converted to underscores
Examples:
database.host→MYAPP_DATABASE_HOSTapi-key→MYAPP_API_KEYcache.ttl-seconds→MYAPP_CACHE_TTL_SECONDS
const zonfig = @import("zonfig");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Create defaults
var defaults = std.json.ObjectMap.init(allocator);
defer defaults.deinit();
try defaults.put("port", .{ .integer = 8080 });
try defaults.put("debug", .{ .bool = false });
var config = try zonfig.loadConfig(allocator, .{
.name = "server",
.defaults = .{ .object = defaults },
});
defer config.deinit();
// Defaults are overridden by files and env vars
const port = config.config.object.get("port").?.integer;
}var config = try zonfig.loadConfig(allocator, .{
.name = "myapp",
.cwd = "/path/to/project",
});
defer config.deinit();var config = try zonfig.loadConfig(allocator, .{
.name = "myapp",
.env_prefix = "CUSTOM", // Uses CUSTOM_* instead of MYAPP_*
});
defer config.deinit();const zonfig = @import("zonfig");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var target = std.json.ObjectMap.init(allocator);
defer target.deinit();
try target.put("a", .{ .integer = 1 });
var source = std.json.ObjectMap.init(allocator);
defer source.deinit();
try source.put("b", .{ .integer = 2 });
const merged = try zonfig.deepMerge(
allocator,
.{ .object = target },
.{ .object = source },
.{ .strategy = .smart }, // or .replace, .concat
);
defer {
var iter = merged.object.iterator();
while (iter.next()) |entry| allocator.free(entry.key_ptr.*);
var obj = merged.object;
obj.deinit();
}
// Result: { "a": 1, "b": 2 }
}.{ .strategy = .replace }
// Arrays are completely replaced
// [1, 2] + [3, 4] = [3, 4].{ .strategy = .concat }
// Arrays are concatenated with deduplication
// [1, 2] + [2, 3] = [1, 2, 3].{ .strategy = .smart }
// Object arrays are merged by key (id, name, key, path, type)
// [{"id": 1, "name": "a"}] + [{"id": 1, "name": "b"}]
// = [{"id": 1, "name": "b"}] // merged by idThe ConfigResult struct contains:
pub const ConfigResult = struct {
config: std.json.Value, // The loaded configuration
source: ConfigSource, // Primary source (.file_local, .file_home, .env_vars, .defaults)
sources: []SourceInfo, // All sources that contributed
loaded_at: i64, // Timestamp
allocator: std.mem.Allocator, // Allocator used
pub fn deinit(self: *ConfigResult) void;
};Zonfig searches for configuration files in this order:
- Project root:
./myapp.json,./myapp.zig - Config directory:
./config/myapp.json,./config/myapp.zig - Hidden config:
./.config/myapp.json,./.config/myapp.zig - Home directory:
~/.config/myapp.json,~/.config/myapp.zig
Extension priority: .json > .zig
Zonfig provides detailed error types:
pub const ZonfigError = error{
ConfigFileNotFound,
ConfigFileInvalid,
ConfigFilePermissionDenied,
ConfigFileSyntaxError,
ConfigValidationFailed,
ConfigSchemaViolation,
EnvVarParseError,
CircularReferenceDetected,
MergeStrategyInvalid,
CacheError,
};Example error handling:
const config = zonfig.loadConfig(allocator, .{
.name = "myapp",
}) catch |err| switch (err) {
error.ConfigFileNotFound => {
// Use defaults or create new config
std.debug.print("No config found, using defaults\n", .{});
return;
},
error.ConfigFileSyntaxError => {
std.debug.print("Invalid JSON in config file\n", .{});
return error.InvalidConfig;
},
else => return err,
};
defer config.deinit();zig build testAll 20 tests passing! Note: There are 4 known memory "leaks" from Zig's JSON parser's internal arena allocator - these are expected and don't affect runtime behavior.
pub fn loadConfig(
allocator: std.mem.Allocator,
options: types.LoadOptions,
) !types.ConfigResultLoad configuration with full error handling.
pub fn tryLoadConfig(
allocator: std.mem.Allocator,
options: types.LoadOptions,
) ?types.ConfigResultLoad configuration, returning null on error (no exceptions).
pub fn deepMerge(
allocator: std.mem.Allocator,
target: std.json.Value,
source: std.json.Value,
options: types.MergeOptions,
) !std.json.ValueDeep merge two JSON values.
pub const LoadOptions = struct {
name: []const u8,
defaults: ?std.json.Value = null,
cwd: ?[]const u8 = null,
validate: bool = true,
cache: bool = true,
cache_ttl: u64 = 300_000,
env_prefix: ?[]const u8 = null,
merge_strategy: MergeStrategy = .smart,
};
pub const MergeStrategy = enum {
replace,
concat,
smart,
};
pub const ConfigSource = enum {
file_local,
file_home,
package_json,
env_vars,
defaults,
};MIT
Contributions welcome! Please ensure:
- All tests pass (
zig build test) - Code follows Zig style guidelines
- New features include tests
Inspired by bunfig by the Stacks team.