@toolcode/envx
v0.1.1
Published
Type-safe config loader with Zod, hot-reload and deep-merge
Maintainers
Readme
@toolcode/envx
@toolcode/envx is a modern, type-safe configuration library for Node.js and TypeScript applications. It combines the power of Zod schema validation with the flexibility of object-based configuration files (JSON, YAML, TOML) and environment variable expansion.
Features
- 🔒 Type-Safe: Returns strict TypeScript types inferred from your Zod schema.
- 📄 Structured Config: Support for nested
env.json,env.yaml, orenv.toml. No more flat.envhell. - 🔥 Hot Reload: Automatically updates configuration when files change without restarting the app.
- 🔗 Deep Merge: Merges multiple configuration files (e.g.,
default->local). - 💲 Env Expansion: Supports bash-style variable substitution
${VAR}and defaults${VAR:-default}. - 🚀 Process Env Sync: Optionally flattens and writes config back to
process.env.
Installation
npm install @toolcode/envx zodFor YAML support:
npm install js-yamlFor TOML support:
npm install @iarna/tomlRequirements:
- Node.js >= 18.0.0
- TypeScript >= 5.3 (optional, but recommended)
Basic Usage
1. Define your schema
// config.ts
import { z } from 'zod';
import { loadConfig } from '@toolcode/envx';
const ConfigSchema = z.object({
server: z.object({
port: z.number().default(3000),
host: z.string(),
}),
database: z.object({
url: z.string(),
password: z.string(),
}),
});
// Load configuration asynchronously
const config = await loadConfig({
schema: ConfigSchema,
files: ['env.json', 'env.local.json'],
});
// config is fully typed!
console.log(config.server.port); // TypeScript knows this is a number2. Synchronous loading
For cases where you need synchronous loading:
import { loadConfigSync } from '@toolcode/envx';
const config = loadConfigSync({
schema: ConfigSchema,
files: ['env.json'],
});3. Create config file (env.json)
{
"server": {
"host": "0.0.0.0"
},
"database": {
"url": "postgres://localhost:5432/mydb",
"password": "${DB_PASSWORD:-secret}"
}
}4. Using different file formats
YAML (env.yaml):
server:
host: "0.0.0.0"
port: 3000
database:
url: "postgres://localhost:5432/mydb"
password: "${DB_PASSWORD:-secret}"TOML (env.toml):
[server]
host = "0.0.0.0"
port = 3000
[database]
url = "postgres://localhost:5432/mydb"
password = "${DB_PASSWORD:-secret}"Note: For TOML support, install @iarna/toml:
npm install @iarna/tomlHot Reloading
Use watchConfig to react to file changes.
Note: envx updates the configuration object, but it is up to your application logic to apply those changes (e.g., reconnecting a database or restarting a server listener).
import { watchConfig } from '@toolcode/envx';
const handle = watchConfig({
schema: ConfigSchema,
files: ['env.json'],
onUpdate: (newConfig) => {
console.log('Config updated!', newConfig.server.port);
// Example: restartServer(newConfig.server.port);
},
onError: (err) => {
console.error('Invalid config update, keeping old config.', err.message);
}
});
// To stop watching:
// handle.dispose();Environment Expansion
envx supports syntax similar to bash:
${VAR}: Replaced with value ofprocess.env.VAR.${VAR:-default}: Used ifVARis missing or empty.
Sync with process.env
If you need to support legacy libraries that read from process.env:
await loadConfig({
schema: ConfigSchema,
processEnv: {
enabled: true,
flattenStrategy: 'UPPER_SNAKE_CASE',
stringifyObjects: true // Convert objects/arrays to JSON strings
}
});
// Nested config becomes flat env vars:
// { server: { port: 3000 } } => process.env.SERVER_PORT = "3000"
// { tags: ['api', 'v1'] } => process.env.TAGS = '["api","v1"]'Note: envx only sets process.env variables that don't already exist, preserving existing environment variables.
CLI
The CLI is useful for validating configuration files in CI/CD pipelines without running the application.
# Check if files are valid (syntax check)
npx envx validate
# Check specific files
npx envx validate --files env.json env.local.json
# Print merged and expanded config (secrets redacted by default)
npx envx print
# Print with secrets visible (use with caution!)
npx envx print --show-secrets
# Print as flat .env format (useful for admin panels or docker-compose)
npx envx print --format env
# Print specific files
npx envx print --files env.json env.local.jsonAPI Reference
loadConfig<T>(options: EnvXOptions<T>): Promise<z.infer<T>>
Loads configuration asynchronously from files and validates against a Zod schema.
loadConfigSync<T>(options: EnvXOptions<T>): z.infer<T>
Loads configuration synchronously. Use when you can't use async/await.
watchConfig<T>(options: WatchOptions<T>): WatchHandle
Watches configuration files for changes and triggers callbacks on updates.
Options:
schema: Zod schema for validationfiles: Array of file paths to load (order matters for merging)required: Throw error if no files found (default:false)expandEnv: Enable environment variable expansion (default:true)processEnv: Configuration for syncing toprocess.envenabled: Enable syncing toprocess.env(default:false)flattenStrategy: How to flatten nested objects (default:'UPPER_SNAKE_CASE')stringifyObjects: Convert non-primitive values to JSON strings (default:true)
onUpdate: Callback for successful config updates (watch mode only)onError: Callback for validation/parse errors (watch mode only)
Advanced Examples
Multiple configuration files with merging
const config = await loadConfig({
schema: ConfigSchema,
files: [
'config/default.json', // Base config
'config/production.json', // Environment-specific
'config/local.json' // Local overrides (gitignored)
]
});
// Later files override earlier ones, with deep mergingHandling missing files
const config = await loadConfig({
schema: ConfigSchema,
files: ['env.json', 'env.local.json'],
required: false // Don't throw if files don't exist
});
// Or require at least one file:
const config = await loadConfig({
schema: ConfigSchema,
files: ['env.json', 'env.local.json'],
required: true // Throw if no files found
});Complex schema with nested objects
const ConfigSchema = z.object({
server: z.object({
port: z.number().min(1).max(65535),
host: z.string().default('localhost'),
cors: z.object({
enabled: z.boolean().default(false),
origins: z.array(z.string()).default([])
})
}),
database: z.object({
url: z.string().url(),
pool: z.object({
min: z.number().default(2),
max: z.number().default(10)
}).optional()
}),
features: z.record(z.string(), z.boolean()).optional()
});Error Handling
envx throws ConfigError for validation failures and file issues. Always wrap config loading in try-catch:
import { loadConfig, ConfigError } from '@toolcode/envx';
try {
const config = await loadConfig({
schema: ConfigSchema,
files: ['env.json']
});
} catch (error) {
if (error instanceof ConfigError) {
console.error('Configuration error:', error.message);
// Handle config-specific errors
} else {
console.error('Unexpected error:', error);
}
process.exit(1);
}Common Errors
Configuration Validation Failed: Your config doesn't match the Zod schema. Check the error message for specific field issues.No configuration files found: No files were found andrequired: truewas set.Failed to parse <file>: Syntax error in JSON/YAML/TOML file.Configuration file <file> is too large: File exceeds 10MB limit.Too many variable expansions: Possible ReDoS attack or circular reference in variable expansion.
Troubleshooting
Environment variables not expanding
If ${VAR} patterns aren't being replaced:
- Ensure
expandEnv: true(default) is set - Check that the variable exists in
process.env - Verify variable name uses only alphanumeric characters and underscores
- For
.envfiles, calldotenv.config()before loading config:
import dotenv from 'dotenv';
dotenv.config(); // Load .env files first
import { loadConfig } from '@toolcode/envx';
const config = await loadConfig({ schema, files: ['env.json'] });TypeScript types not inferred correctly
Make sure your Zod schema is properly typed:
// ✅ Good - types are inferred
const schema = z.object({
port: z.number()
});
type Config = z.infer<typeof schema>; // { port: number }
// ❌ Bad - types are too loose
const schema: z.ZodTypeAny = z.object({
port: z.number()
});Hot reload not working
- Ensure files are being watched (check file paths)
- Verify file changes are saved completely (watcher waits 300ms for stability)
- Check that validation passes after changes (invalid configs won't trigger
onUpdate)
Files not merging correctly
Remember: later files override earlier ones with deep merging:
// base.json: { server: { port: 3000, host: 'localhost' } }
// local.json: { server: { port: 8080 } }
// Result: { server: { port: 8080, host: 'localhost' } }Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
Development
# Clone the repository
git clone https://github.com/Enot-Racoon/toolcode-envx.git
cd envx
# Install dependencies
npm install
# Run tests
npm test
# Build the project
npm run build
# Run demo
npm run demoLicense
MIT © 2025 envx contributors
