npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@lindeneg/cl-env

v0.1.2

Published

Type-safe, leak-free environment variable loader

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.env mutation — returns a plain object, secrets never leak to child processes.
  • Zero dependencies.


Install

npm i @lindeneg/cl-env

Why 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=info

The 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/1true, false/FALSE/0false (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; // number

Or 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. $MISSING stays 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