@0x-config/core
v1.0.0
Published
Runtime environment variable validation for Node, Bun & Browser. Typed. Zero dependencies. Fail-fast.
Maintainers
Readme
@0x-config/core
Runtime environment validation that yells at you before your app blows up in production.
Zero dependencies. Fully typed. Works in Node, Bun, and the browser. Tiny (< 3kb gzipped).
npm i @0x-config/core
pnpm add @0x-config/core
yarn add @0x-config/core
bun add @0x-config/coreThe problem
You've been there. Deploy goes out. Ten minutes later — crash. Somewhere deep in a stack trace:
TypeError: Cannot read properties of undefined (reading 'split')Turns out DATABASE_URL was never set in the prod environment. Your app booted fine, fetched nothing, then exploded the moment it tried to use it.
@0x-config/core fixes this at startup, not at 3am.
Quick start
// config.ts
import { createConfig } from '@0x-config/core'
export const config = createConfig({
PORT: { type: 'port', default: 3000 },
DATABASE_URL: { type: 'url', description: 'PostgreSQL connection string',
example: 'postgresql://user:pass@localhost/mydb' },
JWT_SECRET: { type: 'string', minLength: 32, sensitive: true },
NODE_ENV: { type: 'string', oneOf: ['development', 'production', 'test'] as const },
DEBUG: { type: 'boolean', default: false },
API_URL: { type: 'url', optional: true },
})
// config.PORT → number
// config.DATABASE_URL → string
// config.JWT_SECRET → string
// config.DEBUG → boolean
// config.API_URL → string | undefinedIf anything is missing or invalid when your app starts:
✘ 0x-config — environment validation failed
DATABASE_URL
→ missing required variable (expected type: url)
ℹ PostgreSQL connection string
e.g. DATABASE_URL=postgresql://user:pass@localhost/mydb
JWT_SECRET
→ must be ≥ 32 chars — got 8
2 problems found. Add the missing variables to your .env and restart.All errors at once — not one at a time.
Types
| Type | Input examples | Output type |
|-----------|------------------------------------|-------------|
| string | "hello", "world" | string |
| number | "42", "3.14" | number |
| boolean | "true", "1", "yes", "on" | boolean |
| port | "3000", "8080" | number |
| url | "https://api.example.com" | string |
| email | "[email protected]" | string |
| json | '{"retries":3}' | unknown |
Field options
{
type?: EnvType // default: 'string'
optional?: boolean // no error if missing
default?: T // used when variable is absent
description?: string // printed in error output and .env.example
example?: string // shown as e.g. KEY=value in errors
oneOf?: T[] // allowlist of exact values
validate?: (v: T) => boolean | string // sync custom validator
validateAsync?: (v: T) => Promise<boolean | string> // async validator (createConfigAsync only)
min?: number // number / port minimum
max?: number // number / port maximum
minLength?: number // string / url / email min length
maxLength?: number // string / url / email max length
sensitive?: boolean // always mask value in verbose output
transform?: (v: T) => unknown // map coerced value to any shape
}Fluent builder API
Prefer a chainable syntax? Use c():
import { createConfig, c } from '@0x-config/core'
export const config = createConfig({
PORT: c('port').default(3000).build(),
DATABASE_URL: c('url')
.describe('PostgreSQL connection string')
.example('postgresql://user:pass@localhost/db')
.build(),
JWT_SECRET: c('string').minLength(32).sensitive().build(),
DEBUG: c('boolean').default(false).build(),
NODE_ENV: c('string').oneOf('development', 'production', 'test').build(),
RATE_LIMIT: c('number').min(1).max(10_000).default(100).build(),
})Both styles produce identical results.
getOrThrow — one-off lookups
Need a single variable without defining a full schema:
import { getOrThrow } from '@0x-config/core'
const secret = getOrThrow('JWT_SECRET', 'string')
const port = getOrThrow('PORT', 'port')Custom validators
export const config = createConfig({
PASSWORD: {
type: 'string',
validate: (v) => {
if (v.length < 12) return 'must be at least 12 characters'
if (!/[A-Z]/.test(v)) return 'must contain an uppercase letter'
if (!/[0-9]/.test(v)) return 'must contain a digit'
return true
},
},
})Async validators
When validation requires a network call or external check, use createConfigAsync with validateAsync:
import { createConfigAsync } from '@0x-config/core'
export const config = await createConfigAsync({
DATABASE_URL: {
type: 'url',
validateAsync: async (url) => {
const ok = await pingDatabase(url)
return ok || 'database is unreachable'
},
},
REDIS_URL: {
type: 'url',
validateAsync: async (url) => checkRedis(url),
},
})All async validators run in parallel. Sync errors are reported alongside async errors.
Error handling modes
createConfig(schema, { onError: 'throw' }) // default — throws ConfigError with all issues
createConfig(schema, { onError: 'warn' }) // prints errors to stderr, continues
createConfig(schema, { onError: 'silent' }) // never throws; result.$errors has the listCatch programmatically:
import { createConfig, ConfigError } from '@0x-config/core'
try {
const config = createConfig(schema)
} catch (err) {
if (err instanceof ConfigError) {
for (const e of err.errors) {
console.error(e.key, '→', e.message)
}
}
}Verbose mode
createConfig(schema, { verbose: true })Prints a table of all loaded variables on success:
✔ 0x-config — all variables loaded
VARIABLE TYPE VALUE
────────────────────────────────────────────────────────────
PORT port 3000
DATABASE_URL url postgresql://localhost/mydb
JWT_SECRET string ●●●●●●●●
DEBUG boolean false
NODE_ENV string developmentValues are masked automatically for keys that start with secret, password, api_key, auth, token etc. Mark any field explicitly with sensitive: true to always mask it regardless of the key name.
transform — shape the value at load time
Map a coerced value to any shape right in the schema definition. Runs after all validators pass:
export const config = createConfig({
DATABASE_URL: {
type: 'url',
transform: (v) => new URL(v),
},
ALLOWED_IPS: {
type: 'string',
transform: (v) => v.split(',').map(s => s.trim()),
},
PORT: {
type: 'port',
transform: (v) => ({ port: v, address: `http://localhost:${v}` }),
},
})
config.DATABASE_URL // URL instance
config.ALLOWED_IPS // string[]
config.PORT // { port: number; address: string }Unused variable warnings
Catch dead env vars before they accumulate:
const source = parseDotEnv(readFileSync('.env', 'utf8'))
createConfig(schema, { source, warnUnused: true })
// ⚠ 0x-config — unused environment variables
// ~ LEGACY_STRIPE_KEY
// ~ OLD_API_URLBest used with a parsed .env file rather than process.env (which contains many system variables).
Watch mode (Node.js)
In development, re-validate on every .env change and see exactly what changed:
import { watchConfig } from '@0x-config/core/watch'
const handle = watchConfig(schema, {
envFile: '.env',
onReload: (diff) => console.log('reloaded', diff),
onValidationError: (errors) => console.error('invalid env', errors),
})
// prints on change:
// ~ 0x-config — .env changed
// + NEW_VAR
// - REMOVED_VAR
// ~ DATABASE_URL
// ✔ re-validated successfully
// stop watching:
handle.close()watchConfig is a Node.js-only export — import from @0x-config/core/watch so browser bundles are never affected.
CLI
Validate your environment in CI before deploying, or generate a .env.example from your schema.
Your schema file must export the raw schema object (not the result of createConfig):
// config.schema.ts
export const schema = {
PORT: { type: 'port', default: 3000 },
DATABASE_URL: { type: 'url', description: 'PostgreSQL DSN' },
JWT_SECRET: { type: 'string', minLength: 32, sensitive: true },
} satisfies import('@0x-config/core').SchemaCheck current environment:
# validate process.env against schema
npx @0x-config/core --check --schema ./dist/config.schema.js
# validate a specific .env file
npx @0x-config/core --check --schema ./dist/config.schema.js --env .env.productionExits with code 1 on failure — drop it into any CI pipeline.
Generate .env.example:
npx @0x-config/core --generate-example --schema ./dist/config.schema.js
npx @0x-config/core --generate-example --schema ./dist/config.schema.js --output .env.exampleOutput:
# Generated by 0x-config
# Copy this file to .env and fill in the values
# PostgreSQL DSN
# type: url
DATABASE_URL=https://example.com
# type: port
# default: 3000
PORT=3000
# type: string
JWT_SECRET=Keeps your example file in sync with the actual schema automatically.
Using with .env files
@0x-config/core does not auto-load .env files — pair it with your loader of choice:
// With dotenv
import 'dotenv/config'
import { createConfig } from '@0x-config/core'
export const config = createConfig({ /* ... */ })
// With Bun (built-in .env loading)
export const config = createConfig({ /* ... */ })
// With Vite — import.meta.env is detected automatically
export const config = createConfig({ /* ... */ })Or use the built-in parseDotEnv to handle files yourself:
import { createConfig, parseDotEnv } from '@0x-config/core'
import { readFileSync } from 'fs'
const source = parseDotEnv(readFileSync('.env', 'utf8'))
export const config = createConfig(schema, { source })Prefix support (Vite, Next.js, etc.)
// Maps schema key PORT → process.env.VITE_PORT
createConfig(schema, { prefix: 'VITE_' })
// Maps schema key PORT → process.env.NEXT_PUBLIC_PORT
createConfig(schema, { prefix: 'NEXT_PUBLIC_' })TypeScript
Full type inference — no extra steps needed:
const config = createConfig({
PORT: { type: 'port' }, // → number
DEBUG: { type: 'boolean' }, // → boolean
NAME: { optional: true }, // → string | undefined
})
config.PORT // number ✓
config.DEBUG // boolean ✓
config.NAME // string | undefined ✓transform return types are inferred too:
const config = createConfig({
DATABASE_URL: { type: 'url', transform: (v) => new URL(v) },
})
config.DATABASE_URL // URL ✓The returned object is frozen (Object.freeze) — no accidental mutation of runtime config.
Why not just use Zod?
Zod is fantastic for validating arbitrary data. @0x-config/core is specifically built for the env-loading use case:
- No dependency to install alongside it
- Understands env-specific types (
port,url,email) - Auto-detects the env source (Node, Bun, Vite)
- Error messages are written for developers, not machines
- Watch mode for development
- CLI for CI validation and example generation
- The entire library is < 3kb gzipped
Use Zod for your API schemas. Use @0x-config/core for your environment.
License
MIT
