Type-safe environment variable parsing — validated, coerced, structured configs from process.env.
Environment variables are untyped strings. Most apps access them via process.env.SOME_VAR! — no validation, no coercion, no structure. Missing variables surface as runtime crashes. Secrets accidentally leak into frontend bundles. Config objects are ad-hoc and fragile.
@cleverbrush/env uses @cleverbrush/schema builders for validation and coercion, and a branded env() wrapper for type-safe variable binding. TypeScript enforces at compile time that every config leaf is bound to an environment variable. At runtime, all variables are validated at once — with clear error messages for CI and startup logs.
env() on a config leaf is a TypeScript error.minLength(), .coerce(), .default(), custom validatorssplitBy(',') preprocessor for comma-separated valuesDefine a nested config tree where every leaf is bound to an env var with env(). Types are inferred automatically:
import { env, parseEnv, splitBy } from '@cleverbrush/env';
import { string, number, boolean, array } from '@cleverbrush/schema';
const config = parseEnv({
db: {
host: env('DB_HOST', string().default('localhost')),
port: env('DB_PORT', number().coerce().default(5432)),
name: env('DB_NAME', string()),
},
jwt: {
secret: env('JWT_SECRET', string().minLength(32)),
},
debug: env('DEBUG', boolean().coerce().default(false)),
allowedOrigins: env(
'ALLOWED_ORIGINS',
array(string()).addPreprocessor(splitBy(','), { mutates: false })
),
});
// Inferred type:
// {
// db: { host: string, port: number, name: string },
// jwt: { secret: string },
// debug: boolean,
// allowedOrigins: string[]
// }
config.db.host // string
config.db.port // number (coerced from string)
config.debug // boolean (coerced from "true"/"false")
config.allowedOrigins // string[] (split from "a,b,c")The parseEnv() function only accepts config trees where every leaf is an EnvField (created by env()). Passing a bare schema builder is a TypeScript error:
// ✗ Compile error — forgot env()
parseEnv({
db: {
host: string(),
// ^^^^^^^^ Type 'StringSchemaBuilder' is not
// assignable to type 'EnvConfigNode'
},
});
// ✓ Correct — every leaf wrapped with env()
parseEnv({
db: {
host: env('DB_HOST', string()),
},
});For simple apps where each config key is also the env var name, use parseEnvFlat() — no env() wrapper needed:
import { parseEnvFlat } from '@cleverbrush/env';
import { string, number } from '@cleverbrush/schema';
const config = parseEnvFlat({
DB_HOST: string().default('localhost'),
DB_PORT: number().coerce().default(5432),
JWT_SECRET: string().minLength(32),
});
// Type: { DB_HOST: string, DB_PORT: number, JWT_SECRET: string }Use the splitBy() preprocessor to parse delimited strings into arrays:
import { env, splitBy } from '@cleverbrush/env';
import { array, string, number } from '@cleverbrush/schema';
// Comma-separated strings → string[]
env('ORIGINS', array(string()).addPreprocessor(splitBy(','), { mutates: false }))
// "https://a.com, https://b.com" → ['https://a.com', 'https://b.com']
// Comma-separated numbers → number[] (coerced per element)
env('PORTS', array(number().coerce()).addPreprocessor(splitBy(','), { mutates: false }))
// "3000, 4000, 5000" → [3000, 4000, 5000]Derive values from the resolved config by passing a compute callback as the second argument. The callback receives the fully typed base config and returns an object that is deep-merged into the result:
import { env, parseEnv } from '@cleverbrush/env';
import { string, number } from '@cleverbrush/schema';
const config = parseEnv(
{
db: {
host: env('DB_HOST', string().default('localhost')),
port: env('DB_PORT', number().coerce().default(5432)),
name: env('DB_NAME', string()),
},
},
(base) => ({
db: {
connectionString: `postgres://${base.db.host}:${base.db.port}/${base.db.name}`,
},
})
);
// base is fully typed: { db: { host: string, port: number, name: string } }
// Result: { db: { host, port, name, connectionString } }
config.db.connectionString // "postgres://localhost:5432/mydb"When using a compute callback, the optional source is passed as the third argument:
const config = parseEnv(
{ host: env('HOST', string()) },
(base) => ({ url: `http://${base.host}` }),
{ HOST: 'example.com' } // custom source
);When variables are missing or invalid, parseEnv() throws an EnvValidationError with a formatted message listing every problem at once:
try {
const config = parseEnv({ ... });
} catch (e) {
if (e instanceof EnvValidationError) {
console.error(e.message);
// Missing environment variables:
// - DB_NAME (required by db.name) [string]
// - JWT_SECRET (required by jwt.secret) [string]
// Invalid environment variables:
// - DB_PORT: "abc" (required by db.port) — number expected
// Programmatic access:
e.missing // [{ varName, configPath, type }]
e.invalid // [{ varName, configPath, value, errors }]
}
}| Feature | @cleverbrush/env | t3-env | envalid |
|---|---|---|---|
| Compile-time leaf enforcement | ✓ | ✗ | ✗ |
| Nested config structures | ✓ | ✗ | ✗ |
| Schema-based validation | ✓ | ✓ | ~ |
| Type coercion | ✓ | Manual | ✓ |
| Array support | ✓ | ✗ | ✗ |
| Computed / derived values | ✓ | ✗ | ✗ |
| All-at-once error reporting | ✓ | ✓ | ✓ |
| Export | Type | Description |
|---|---|---|
env(varName, schema) | Function | Binds a schema to an env var name. Required for every leaf in parseEnv(). |
parseEnv(config, source?) | Function | Parses env vars into a validated, typed nested config object. |
parseEnv(config, compute, source?) | Function | Parses env vars, then deep-merges computed values from the callback. |
parseEnvFlat(schemas, source?) | Function | Flat convenience — keys are env var names, no env() needed. |
splitBy(separator) | Function | Preprocessor that splits a string into an array. |
EnvValidationError | Class | Thrown when env vars are missing or invalid. Has .missing and .invalid properties. |
EnvField<T> | Type | Branded wrapper type created by env(). |
InferEnvConfig<T> | Type | Infers the runtime type from a config descriptor. |