@cleverbrush/env

Type-safe environment variable parsing — validated, coerced, structured configs from process.env.

$npm install @cleverbrush/env @cleverbrush/schema

Requires @cleverbrush/schema as a peer dependency.

💡 Why @cleverbrush/env?

The Problem

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.

The Solution

@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.

Key Features

  • Compile-time enforcement — forgetting env() on a config leaf is a TypeScript error
  • Nested configs — nest objects arbitrarily deep; env vars map to leaf fields
  • Full schema power .minLength(), .coerce(), .default(), custom validators
  • Array support splitBy(',') preprocessor for comma-separated values
  • Computed values — derive values from resolved config via a type-safe callback, deep-merged into the result
  • All-at-once errors — lists every missing and invalid variable in one message

Quick Start — Structured Config

Define 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")

Compile-Time Enforcement

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()),
  },
});

Flat Mode

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 }

Array Support

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]

Computed Values

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
);

Error Reporting

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 }]
  }
}

Comparison

Feature@cleverbrush/envt3-envenvalid
Compile-time leaf enforcement
Nested config structures
Schema-based validation~
Type coercionManual
Array support
Computed / derived values
All-at-once error reporting

API Reference

ExportTypeDescription
env(varName, schema)FunctionBinds a schema to an env var name. Required for every leaf in parseEnv().
parseEnv(config, source?)FunctionParses env vars into a validated, typed nested config object.
parseEnv(config, compute, source?)FunctionParses env vars, then deep-merges computed values from the callback.
parseEnvFlat(schemas, source?)FunctionFlat convenience — keys are env var names, no env() needed.
splitBy(separator)FunctionPreprocessor that splits a string into an array.
EnvValidationErrorClassThrown when env vars are missing or invalid. Has .missing and .invalid properties.
EnvField<T>TypeBranded wrapper type created by env().
InferEnvConfig<T>TypeInfers the runtime type from a config descriptor.