@systemix/env

Typed environment variable loading and validation. Zero dependencies. Built-in .env file support. Requires Node.js 24.7+.

Installation

ts
pnpm add @systemix/env

Basic Usage

ts
import { load } from '@systemix/env';

const env = load({
  NODE_ENV: { type: 'string', default: 'development' },
  PORT: { type: 'number', default: 3000 },
  DATABASE_URL: { type: 'string', required: true, secret: true },
  DEBUG: { type: 'boolean', default: false },
});

console.log(env.PORT); // number
console.log(env.toSafeLog()); // { DATABASE_URL: '***', ... }

Schema

PropertyTypeDescription
type'string' | 'number' | 'boolean'How to parse the value
requiredbooleanIf true, throws when missing
defaultstring | number | booleanUsed when var is missing or empty
minnumberMin value (number type)
maxnumberMax value (number type)
regexRegExpMust match (string type)
oneOfstring[]Allowed values (string type)
transform(raw) => valueCustom parser
secretbooleanMasked in toSafeLog()

Options

ts
load(schema, {
  source: { PORT: '4000' }, // env source (default: process.env)
  fromFile: ['.env', '.env.local'], // .env file(s); later files override earlier ones
  strict: true, // error on keys in a .env file that are not in the schema
});

Precedence

Highest first: source (the real environment), then fromFile files (later over earlier), then schema defaults. A .env file never overrides a variable that is really set, so a stray .env in production cannot replace deployed secrets. Missing files are skipped.

strict

With strict: true, a .env file that contains a key the schema doesn't know is an error, which catches typos like DATABSE_URL that would otherwise be silently ignored. Unrelated variables in the real environment are not flagged.

Standard Schema (Zod, Valibot, ArkType)

Any Standard Schema validator can validate a variable. It receives the raw string, so coercing schemas work, and the result type is the schema's output type:

ts
import { z } from 'zod';

const env = load({
  PORT: { schema: z.coerce.number().int().min(1).max(65535), default: 3000 },
  DATABASE_URL: { schema: z.url(), required: true, secret: true },
});

env.PORT; // number
env.DATABASE_URL; // string

required, default (checked against the output type) and secret work the same as for built-in types. Validators must be synchronous; an async one fails with a clear error. This package doesn't depend on any validator library.

.env Files

ts
const env = load(schema, { fromFile: '.env.local' });

The parser (also exported as parseEnvFile) supports:

  • KEY=value with an optional export prefix, and # comment lines
  • inline comments: KEY=value # note (a # with no space before it, as in URLs, is kept)
  • "double quotes": may span lines; \n \r \t \" \\ are expanded, other backslashes kept
  • 'single quotes': literal, may span lines
  • invalid keys and lines without = are skipped
ts
import { parseEnvFile } from '@systemix/env';

const vars = parseEnvFile(readFileSync('.env', 'utf-8'));

Secret Masking

Mark sensitive vars with secret: true:

ts
const env = load(
  {
    API_KEY: { type: 'string', secret: true },
    PORT: { type: 'number' },
  },
  { source: { API_KEY: 'sk-123', PORT: '3000' } },
);

env.API_KEY; // 'sk-123' — still readable
JSON.stringify(env); // '{"PORT":3000}' — secrets are not enumerable
console.log(env); // secrets don't appear
env.toSafeLog(); // { API_KEY: '***', PORT: 3000 }

Secrets are non-enumerable (so logging, JSON.stringify and spread don't expose them), never appear in validation errors, and the result object is frozen.

Batch Error Reporting

All validation errors are collected and thrown together:

ts
Env validation failed:
  - Missing required env var: DATABASE_URL
  - PORT: must be >= 1

Config (OOP)

Class-based usage with get, getOrThrow, has:

ts
import { Config } from '@systemix/env';

const config = Config.fromEnv({
  PORT: { type: 'number', default: 3000 },
  DATABASE_URL: { type: 'string', required: true },
});

config.get('PORT'); // number | undefined
config.get('PORT', 4000); // number (default when undefined)
config.getOrThrow('DATABASE_URL'); // throws if not set
config.has('PORT'); // boolean
config.toSafeLog(); // masked for logging
config.getRaw(); // raw env object

Create from existing env: Config.from(load(schema)).

Number and Boolean Parsing

  • Numbers must be decimal (42, -5, .5, 1e3). Empty or whitespace-only input, 0x10, Infinity and 1_000 are rejected.
  • Booleans (case-insensitive): true, 1, yes, on and false, 0, no, off.
  • An empty variable counts as unset: the default applies, or a required variable is reported missing.

Try it

Open the Env Loader demo to see live examples.