@lindeneg/cl-env
v0.1.2
Published
Type-safe, leak-free environment variable loader
Maintainers
Readme
under development with possible breaking changes until 1.0.0 is released
cl-env
Load .env files, validate values with composable transforms, and produce a fully typed configuration object, all with zero runtime dependencies.
- Full type inference — transforms, defaults, key casing all reflected at the type level.
- Proper dotenv parser — multiline values, escape sequences, variable expansion, inline comments, layered files.
- Composable validation — combine
withRequired,withDefault, built-in transforms, or write your own. - Structured errors — errors accumulate; nothing fails silently.
- No
process.envmutation — returns a plain object, secrets never leak to child processes. - Zero dependencies.
Install
npm i @lindeneg/cl-envWhy cl-env?
cl-env owns the full env loading pipeline: parsing, variable expansion, layered files, validation, and typing, in a single zero-dependency package that never mutates process.env.
If your framework already manages process.env for you, validation-only libraries like t3-env or envalid are purpose-built for that model. cl-env is for when you want to control the loading yourself.
| | Common approach | cl-env | |---|---|---| | Parsing | dotenv (separate package) | Built-in | | Typing | Via schema library (Zod, etc.) | Inferred from transforms | | Validation | Schema-based | Transform-based | | Expansion | dotenv-expand (separate package) | Built-in, graph-based | | Layering | dotenv-flow (separate package) | Built-in | | Errors | Varies | Accumulated with source tracking | | Dependencies | 2-4 packages | Zero |
Quick start
import { loadEnv, unwrap, toString, toInt, toFloat, toBool, toEnum,
withOptional, withDefault, withRequired } from "@lindeneg/cl-env";
const env = unwrap(
loadEnv(
{ files: [".env"], transformKeys: true },
{
DATABASE_URL: withRequired(toString),
PORT: withDefault(toInt, 3000),
FLOAT: withOptional(toFloat),
DEBUG: toBool,
LOG_LEVEL: toEnum("debug", "info", "warn", "error"),
}
)
);Given this .env file:
DATABASE_URL=postgres://localhost/db
PORT=8080
DEBUG=true
LOG_LEVEL=infoThe result is a fully typed object:
env.databaseUrl // string
env.port // number
env.float // number | undefined
env.debug // boolean
env.logLevel // "debug" | "info" | "warn" | "error"unwrap extracts the data or throws if any errors occurred. Key casing, transforms, defaults, and optionals are all inferred at the type level.
An async version is also available:
const env = unwrap(
await loadEnvAsync(
{ files: [".env"], transformKeys: true },
{ /* same config */ }
)
);loadEnvAsync has the same signature and type inference as loadEnv but reads files concurrently using fs/promises and returns a Promise.
API
Options
Options are passed inline as the first argument to loadEnv / loadEnvAsync. The options type is intentionally not exported. Pass options inline so TypeScript can infer the literal type of transformKeys and produce the correct key casing in the result.
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
| files | string[] | yes | — | Required files to load, in order. Every file must be readable. Duplicate keys use last-wins. |
| optionalFiles | string[] | no | [] | Files to load if they exist, silently skipped otherwise. Read after files, same last-wins rule. |
| transformKeys | boolean | yes | — | Convert SCREAMING_SNAKE_CASE keys to camelCase in the result (runtime + type level). Only fully uppercase keys are transformed; mixed-case keys like helloThere are preserved. |
| basePath | string | no | — | Prepended to each file path. |
| encoding | BufferEncoding | no | "utf8" | File encoding. |
| includeProcessEnv | "fallback" | "override" | false | no | false | "fallback": fills in keys missing from files. "override": process.env wins over file values. false: ignore process.env. Only keys defined in your config are read. See details below. |
| logger | Logger | boolean | no | — | true for built-in colored logger, or a (level, message) => void function. Levels: "error", "warn", "debug", "verbose". |
| schemaParser | SchemaParser | no | — | Validation function for toJSON transforms. See schema validation. |
| radix | (key: string) => number \| undefined | no | — | Per-key radix for toInt. Return undefined for default (base 10). |
includeProcessEnv details
The merge happens after variable expansion. Values from process.env are taken as-is. $VAR references in them are not expanded. In "fallback" mode, a key with an empty value in a file (KEY=) is considered present, so process.env will not replace it.
Schema validation
Pass a schemaParser in options and a schema to toJSON(schema):
import { loadEnv, toJSON, success, failure, type SchemaParser } from "@lindeneg/cl-env";
const parser: SchemaParser = (obj, schema, key) => {
const result = schema.safeParse(obj);
if (result.success) return success(result.data);
return failure(`${key}: ${result.error.message}`);
};
loadEnv(
{ files: [".env"], transformKeys: false, schemaParser: parser },
{ DB_CONFIG: toJSON<DbConfig>(dbConfigSchema) }
);If a schema is passed to toJSON but no schemaParser is set in options, it fails with an error.
Transforms
Each config value is a transform function (key, value, ctx) => Result<T>, where value is string | undefined (undefined means the key was not found in any source).
| Transform | Output | Notes |
|---|---|---|
| toString | string | Returns value as-is |
| toInt | number | parseInt, respects radix option. Note: parseInt ignores trailing non-numeric characters (e.g. '42abc' → 42). Use a custom transform for strict validation. |
| toFloat | number | parseFloat |
| toBool | boolean | true/TRUE/1 → true, false/FALSE/0 → false (case-insensitive). Anything else fails. |
| toEnum(...values) | union | Succeeds if value matches exactly (case-sensitive). Type is inferred as union of provided strings. |
| toJSON<T>(schema?) | T | JSON.parse, optionally validated via schemaParser. |
| toStringArray(delim?) | string[] | Split by delimiter (default ,), trim each element. |
| toIntArray(delim?) | number[] | Split and parse each as integer. |
| toFloatArray(delim?) | number[] | Split and parse each as float. |
All built-in transforms fail on undefined with a message suggesting withDefault or withRequired.
Custom transforms
Return success(value) or failure(message). TypeScript infers the result type from your success(...) calls:
import { loadEnv, unwrap, success, failure } from "@lindeneg/cl-env";
const env = unwrap(
loadEnv(
{ files: [".env"], transformKeys: false },
{
CREATED: (key, v) => {
if (v === undefined) return failure(`${key}: no value provided`);
const d = new Date(v);
if (isNaN(d.getTime())) return failure(`${key}: invalid date '${v}'`);
return success(d);
},
}
)
);
// env: { CREATED: Date }The TransformFn type is exported for writing reusable transforms in separate files.
TransformContext
Every transform receives a TransformContext as its third argument:
| Property | Type | Description |
|---|---|---|
| expandedEnv | Record<string, string> | All resolved string values (post-expansion, pre-transform). |
| source | string \| undefined | Where the key came from: file name (e.g. ".env.local"), "process.env", or "none". |
| line | number \| undefined | Line number in the source file. undefined when there is no file. |
| schemaParser | SchemaParser \| undefined | The schema parser from options, if set. |
| radix | ((key: string) => number \| undefined) \| undefined | The radix function from options, if set. |
| log | Logger \| undefined | The logger from options, if set. |
Behavior
Missing values
You control how missing variables behave with wrappers:
| Wrapper | Missing key | Present key |
|---|---|---|
| withRequired(transform) | Fails with error | Delegates to transform |
| withDefault(transform, value) | Uses default value | Delegates to transform |
| withOptional(transform) | Returns undefined | Delegates to transform |
A key is "missing" when it doesn't appear in any file (or process.env, if merged), its value is undefined. A key with an empty value (KEY=) is not missing; the empty string is passed to the inner transform as-is.
Without a wrapper, a missing key passes undefined directly to the transform. All built-in transforms fail on undefined with a message suggesting withDefault or withRequired.
Result type
loadEnv never throws. It returns Result<T, EnvError[]>:
const result = loadEnv(opts, config);
if (!result.ok) {
for (const err of result.ctx) {
console.error(`${err.source}:L${err.line}: ${err.key}: ${err.message}`);
}
process.exit(1);
}
result.data.PORT; // numberOr use unwrap(result) to extract the data or throw:
const env = unwrap(loadEnv(opts, config));Error handling
Errors are accumulated. All config keys are validated, and every failure is reported, not just the first one.
type EnvError = {
key: string;
line?: number;
source?: string;
message: string;
};unwrap(result) throws an Error with all messages joined by newlines. The success(data) and failure(ctx) constructors are exported for writing custom transforms.
File resolution
Every file listed in files must be readable. A missing required file is always an error, even if all config keys could be satisfied by other files or process.env. Use optionalFiles for files that may or may not exist.
At least one source of values must be configured:
| files | optionalFiles | includeProcessEnv | Result |
|---|---|---|---|
| [] | none | false/undefined | Error: no sources configured |
| [".env"] | — | any | .env must exist, error if missing |
| [".env"] | [".env.local"] | any | .env required; .env.local loaded if present, skipped if not |
| [".env"] (missing) | [".env.local"] (exists) | any | Error: required file missing |
| [] | [".env"] | false/undefined | OK, optional files are a valid source |
| [] | none | "fallback" or "override" | OK, process.env is a valid source |
Variable expansion
Values can reference other variables using $VAR or ${VAR}:
HOST=localhost
PORT=3000
URL=http://${HOST}:$PORT- Expansion runs after deduplication (last-wins) in dependency order (topological sort), so forward references work regardless of file order.
- References resolve against other keys in the files first, then fall back to
process.env. - Unresolved references are left unchanged (e.g.
$MISSINGstays as$MISSING). - Cyclic references are detected and logged as warnings. Values are expanded best-effort but may be incomplete.
- Single-quoted values are not expanded (they're literal).
Parsing rules
The parser is a character-by-character state machine:
| Feature | Behavior |
|---|---|
| Comments | # lines. Inline # preceded by whitespace in unquoted values. |
| Export prefix | export KEY=value supported (prefix stripped). |
| Double quotes | Escape sequences (\n, \r, \t, \\, \"), multiline. |
| Single quotes / backticks | Literal (no escapes), multiline. |
| Unquoted values | Single line, trailing whitespace trimmed. |
| Line endings | BOM (\uFEFF) stripped, \r\n and \r normalized to \n. |
| Line tracking | Line numbers included in all error/warning messages. |
| Unterminated quotes | Warning logged. Value consumes to EOF; subsequent entries in that file will be missing. |
| Invalid keys | Names not matching [A-Za-z_][A-Za-z0-9_]* produce a warning. |
License
MIT
