Dev-friendly TypeScript configuration library with Zod
npm install zfig

Dev-friendly TypeScript config library wrapping Zod with multi-source value resolution.
By @rstagi
``bash`
npm install zfig zod
Creates a type-safe config schema from a definition object.
`typescript
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()
- Field configs - created with
- Nested objects - recursively processed
- Raw Zod types - passed through directly
Marks a config field with resolution metadata.
`typescript`
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())
})
| 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 become z.literal() types:
`typescript`
schema({
version: "1.0", // z.literal("1.0")
port: 3000, // z.literal(3000)
debug: true, // z.literal(true)
});
Nesting supports arbitrary depth:
`typescript`
schema({
db: {
primary: {
host: field({ type: z.string() }),
port: field({ type: z.number() }),
},
replica: {
host: field({ type: z.string() }),
},
},
});
Schemas can be nested inside other schemas. Metadata is preserved.
`typescript
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()
`
`typescript
import { resolve } from "zfig";
const config = resolve(configSchema, {
configPath: "./config.json",
});
`
If configPath not provided, resolve() reads from CONFIG_PATH env var:
`bash`
CONFIG_PATH=./config.json node app.js
JSON is supported by default:
`json`
{
"db": {
"host": "localhost",
"port": 5432
}
}
Install @zfig/yaml-loader for YAML support:
`bash`
npm install @zfig/yaml-loader
`typescript
import "@zfig/yaml-loader"; // side-effect import registers loader
import { resolve } from "zfig";
const config = resolve(configSchema, { configPath: "./config.yaml" });
`
Provide baseline values that can be overridden by config files, env vars, or override:
`typescript`
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
Use z.coerce.* for automatic type conversion from env vars:
`typescript`
schema({
port: field({ type: z.coerce.number(), env: "PORT" }), // "8080" → 8080
debug: field({ type: z.coerce.boolean(), env: "DEBUG" }), // "true" → true
});
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?"
Scenario: Your app connects to the wrong database in staging.
`typescript`
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:
`typescript
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.
`typescript
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 |
Get values and sources together — useful for startup logs or admin endpoints:
`typescript`
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.
For deeper debugging, get the full resolution trace — what sources were checked for each value:
`typescript
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:
`typescript`
config.toDebugObject({ includeDiagnostics: true });
// { config: {...}, diagnostics: [...] }
Mark fields as sensitive to prevent accidental exposure:
`typescript
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
Register custom loaders for different file formats:
`typescript
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:
`typescript`
type FileLoader = (path: string) => Record
Return undefined if file doesn't exist. Throw on parse errors.
ConfigError is thrown when resolution fails:
`typescript
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
``
ConfigError: Missing required config value at path "db.password"
Provide value via env var, secret file, config file, or default.
``
ConfigError: Validation failed at path "port": Expected number, received string
Use z.coerce.number() for env vars that need type conversion.
``
ConfigError: No loader registered for extension ".yaml"
Install and import @zfig/yaml-loader for YAML support.
Check configPath option or CONFIG_PATH env var. JSON loader returns undefined for missing files (no error).
- Check secretFile path is correct/secrets
- Default secrets base path is secretsPath
- Use option in resolve to change base path
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.
`typescript`
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" }),
});
| 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 |
| Function | Description |
|----------|-------------|
| registerLoader(ext, loader) | Register file loader for extension |getLoader(ext)
| | Get loader for extension |getSupportedExtensions()
| | List registered extensions |clearLoaders()
| | Remove all loaders |
| Class | Description |
|-------|-------------|
| ConfigError | Error with path and sensitive properties |
`typescript``
resolve(schema, {
configPath?: string, // path to config file
env?: Record
secretsPath?: string, // base path for secrets (default: "/secrets")
initialValues?: object, // base values
override?: object, // override all sources
});
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.
MIT