zfig
v0.9.15
Published
Dev-friendly TypeScript configuration library with Zod
Maintainers
Readme
Install
npm install zfig zodSchema & Field
schema(definition)
Creates a type-safe config schema from a definition object.
import { schema, field } from "zfig";
import { z } from "zod";
const config = schema({
appName: "my-app", // literal value
port: field({ type: z.number() }), // field config
db: { // nested object
host: field({ type: z.string() }),
},
});Definition values can be:
- Literals - strings, numbers, booleans (become
z.literal()) - Field configs - created with
field() - Nested objects - recursively processed
- Raw Zod types - passed through directly
field(config)
Marks a config field with resolution metadata.
field({
type: z.string(), // required - Zod type validator
env: "DB_HOST", // env var name
secretFile: "db-password", // path to secret file
sensitive: true, // redact in logs/errors
default: "localhost", // fallback value
doc: "Database hostname", // description (becomes .describe())
})Field Options
| Option | Type | Description |
|--------|------|-------------|
| type | ZodType | Required. Zod schema for validation |
| env | string | Environment variable name |
| secretFile | string | Path to file containing secret value |
| sensitive | boolean | Redact value in toString/errors/debug |
| default | unknown | Default value if no source provides one |
| doc | string | Documentation (converted to Zod .describe()) |
Literals & Nesting
Literals become z.literal() types:
schema({
version: "1.0", // z.literal("1.0")
port: 3000, // z.literal(3000)
debug: true, // z.literal(true)
});Nesting supports arbitrary depth:
schema({
db: {
primary: {
host: field({ type: z.string() }),
port: field({ type: z.number() }),
},
replica: {
host: field({ type: z.string() }),
},
},
});Composable Schemas
Schemas can be nested inside other schemas. Metadata is preserved.
const dbSchema = schema({
host: field({ type: z.string(), env: "DB_HOST" }),
port: field({ type: z.number(), default: 5432 }),
});
const appSchema = schema({
db: dbSchema,
name: field({ type: z.string() }),
});
// dbSchema metadata accessible via appSchema.shape.db.shape.host.meta()Config Files
Using resolve()
import { resolve } from "zfig";
const config = resolve(configSchema, {
configPath: "./config.json",
});CONFIG_PATH Environment Variable
If configPath not provided, resolve() reads from CONFIG_PATH env var:
CONFIG_PATH=./config.json node app.jsJSON Support
JSON is supported by default:
{
"db": {
"host": "localhost",
"port": 5432
}
}YAML Support
Install @zfig/yaml-loader for YAML support:
npm install @zfig/yaml-loaderimport "@zfig/yaml-loader"; // side-effect import registers loader
import { resolve } from "zfig";
const config = resolve(configSchema, { configPath: "./config.yaml" });Initial Values
Provide baseline values that can be overridden by config files, env vars, or override:
const config = resolve(configSchema, {
initialValues: { db: { host: "dev-host", port: 5433 } },
configPath: "./config.json",
});Resolution priority: override > env > secretFile > configFile > initialValues > default.
Use cases:
- Programmatic defaults that differ from schema defaults
- Framework/library defaults that apps can override
- Test fixtures with sensible baseline values
Type Coercion
Use z.coerce.* for automatic type conversion from env vars:
schema({
port: field({ type: z.coerce.number(), env: "PORT" }), // "8080" → 8080
debug: field({ type: z.coerce.boolean(), env: "DEBUG" }), // "true" → true
});Debugging
With multiple config sources (env, files, secrets, defaults), it's easy to lose track of where a value came from. Source tracing helps you answer: "Why is the database connecting to the wrong host?"
Why You Need This
Scenario: Your app connects to the wrong database in staging.
const config = resolve(configSchema, { configPath: "./config.json" });
console.log(config.db.host); // "prod-db.example.com" — but why?Without source tracing, you'd have to manually check: env vars? config file? secrets? defaults?
With zfig, just ask:
import { getSources } from "zfig";
console.log(getSources(config));
// {
// "db.host": "env:DB_HOST", ← env var is overriding your config file!
// "db.port": "file:./config.json",
// "db.password": "secretFile:db-password"
// }Now you know: someone set DB_HOST in the environment, overriding your config file.
Source Tracking
import { resolve, getSources } from "zfig";
const config = resolve(configSchema, { configPath: "./config.json" });
// Map of field path → source identifier
getSources(config);
// { "db.host": "env:DB_HOST", "db.port": "file:./config.json", "name": "default" }
// As JSON string (useful for logging)
config.toSourceString();
// '{"db.host":"env:DB_HOST","db.port":"file:./config.json","name":"default"}'Source identifiers:
| Identifier | Meaning |
|------------|---------|
| env:VAR_NAME | Environment variable |
| file:./path | Config file |
| secretFile:name | Secret file |
| default | Schema default value |
| initial | initialValues option |
| override | override option |
| literal | Literal value in schema |
Debug Object
Get values and sources together — useful for startup logs or admin endpoints:
config.toDebugObject();
// {
// config: {
// "db": {
// "host": { value: "prod-db.example.com", source: "env:DB_HOST" },
// "port": { value: 5432, source: "file:./config.json" },
// "password": { value: "[REDACTED]", source: "secretFile:db-password" }
// }
// }
// }Sensitive values are automatically redacted.
Diagnostics
For deeper debugging, get the full resolution trace — what sources were checked for each value:
import { getDiagnostics } from "zfig";
getDiagnostics(config);
// [
// { type: "configPath", picked: "./config.json", candidates: ["option:./config.json"], reason: "provided" },
// { type: "loader", format: ".json", used: true },
// { type: "sourceDecision", key: "db.host", picked: "env:DB_HOST", tried: ["env:DB_HOST", "file:./config.json", "default"] },
// { type: "sourceDecision", key: "db.port", picked: "file:./config.json", tried: ["env:DB_PORT", "file:./config.json", "default"] }
// ]The tried array shows all sources checked in priority order. Useful when you expected a value from one source but another took precedence.
Event types:
configPath— which config file was selected and whyloader— which file format loader was usedsourceDecision— which source provided each value, and what else was triednote— additional info messages
Include diagnostics in debug object:
config.toDebugObject({ includeDiagnostics: true });
// { config: {...}, diagnostics: [...] }Sensitive Values
Mark fields as sensitive to prevent accidental exposure:
schema({
apiKey: field({
type: z.string(),
env: "API_KEY",
sensitive: true,
}),
});
const config = resolve(configSchema);
config.toString();
// '{"apiKey":"[REDACTED]"}'
config.toDebugObject();
// { config: { apiKey: { value: "[REDACTED]", source: "env:API_KEY" } } }Sensitive values are redacted in:
toString()outputtoDebugObject()output- Error messages
Loader Registry
Register custom loaders for different file formats:
import { registerLoader, getLoader, getSupportedExtensions, clearLoaders } from "zfig";
// Register a loader
registerLoader(".toml", (path) => {
const content = fs.readFileSync(path, "utf-8");
return toml.parse(content);
});
// Get loader for extension
const loader = getLoader(".toml");
// List supported extensions
getSupportedExtensions(); // [".json", ".toml"]
// Clear all loaders
clearLoaders();Loader signature:
type FileLoader = (path: string) => Record<string, unknown> | undefined;Return undefined if file doesn't exist. Throw on parse errors.
Error Handling
ConfigError is thrown when resolution fails:
import { ConfigError } from "zfig";
try {
const config = resolve(configSchema);
} catch (e) {
if (e instanceof ConfigError) {
console.log(e.message); // error description
console.log(e.path); // "db.host" (dot-notation path)
console.log(e.sensitive); // true if value should be redacted
console.log(e.diagnostics); // diagnostic events collected before error
}
}Thrown when:
- Required field has no value from any source
- Zod validation fails
- Config file has invalid JSON/YAML
- File extension has no registered loader
Troubleshooting
Missing required config
ConfigError: Missing required config value at path "db.password"Provide value via env var, secret file, config file, or default.
Type validation failed
ConfigError: Validation failed at path "port": Expected number, received stringUse z.coerce.number() for env vars that need type conversion.
Unsupported file extension
ConfigError: No loader registered for extension ".yaml"Install and import @zfig/yaml-loader for YAML support.
Config file not found
Check configPath option or CONFIG_PATH env var. JSON loader returns undefined for missing files (no error).
Secrets not loading
- Check
secretFilepath is correct - Default secrets base path is
/secrets - Use
secretsPathoption in resolve to change base path
Advanced Usage
All Zod features work in field types - .coerce, .nonempty(), .min(), .transform(), etc. Validation is fully delegated to Zod, so you can use any schema features. The only exception is .meta() which zfig uses internally for field metadata and will be overridden.
schema({
port: field({ type: z.coerce.number().min(1).max(65535), env: "PORT" }),
tags: field({ type: z.array(z.string()).nonempty(), default: ["default"] }),
email: field({ type: z.string().email(), env: "ADMIN_EMAIL" }),
});API Reference
Core Functions
| Function | Description |
|----------|-------------|
| schema(definition) | Create config schema |
| field(config) | Create field with metadata |
| resolve(schema, options?) | Resolve values with file loading |
| resolveValues(schema, options?) | Resolve values without file loading |
| getSources(config) | Get source map from resolved config |
| getDiagnostics(config) | Get diagnostic events from resolved config |
Loader Registry
| Function | Description |
|----------|-------------|
| registerLoader(ext, loader) | Register file loader for extension |
| getLoader(ext) | Get loader for extension |
| getSupportedExtensions() | List registered extensions |
| clearLoaders() | Remove all loaders |
Error Class
| Class | Description |
|-------|-------------|
| ConfigError | Error with path and sensitive properties |
Resolve Options
resolve(schema, {
configPath?: string, // path to config file
env?: Record<string, string>, // env vars (default: process.env)
secretsPath?: string, // base path for secrets (default: "/secrets")
initialValues?: object, // base values
override?: object, // override all sources
});Performance
zfig is designed for startup-time config loading where correctness and debuggability matter more than raw speed. That said, it performs well:
| Scenario | zfig | vs zod-config | vs convict | vs @t3-oss/env-core | |----------|--------|---------------|------------|---------------------| | Env only | 704K ops/sec | - | - | 20x faster | | Env + validation | 763K ops/sec | 0.20x | 4.2x faster | 22x faster | | File + nested | 74K ops/sec | 0.70x | 2.2x faster | - |
Key points:
- Fastest for simple env-only loading (1.7x faster than envalid)
- Multi-source resolution adds overhead vs single-source libs
- 74K ops/sec = ~13μs per resolve - plenty fast for startup config
See benchmark/ for full comparison.
License
MIT
