---
title: Validation & Parsing
description: Reference for Envlock schema definition, environment variable parsing, validation, error handling, projection functions, and dotenv utilities.
url: https://pr-1-b9e16090e83d.thally.app/envlock/validation
---

# Validation & Parsing

Reference for Envlock schema definition, environment variable parsing, validation, error handling, projection functions, and dotenv utilities.

# Validation and Parsing

Envlock validates environment variables in a single pass, collecting every issue before reporting. This page covers schema creation, parsing, error handling, projection utilities, and dotenv support.

## Defining a Schema

### `defineEnv(shape)`

Creates an `EnvSchema` from a shape object mapping variable names to field declarations.

```ts
function defineEnv<const Shape extends Record<string, AnyField>>(
  shape: Shape,
): EnvSchema<Shape>;
```

Variable names must match the pattern `^[A-Za-z_][A-Za-z0-9_]*$` -- letters, digits, and underscores, not starting with a digit. A `TypeError` is thrown for any name that does not match:

```
Invalid environment variable name "2FAST": use letters, digits and underscores, not starting with a digit
```

The returned `EnvSchema` is deeply frozen and carries three properties:

- `kind` -- always `"envlock.schema"`
- `shape` -- the frozen shape object you passed in
- `keys` -- a frozen array of variable names in declaration order

```ts
import { defineEnv, env } from "@envlock/core";

const schema = defineEnv({
  PORT: env.port().default(3000),
  DATABASE_URL: env.url({ protocols: ["postgres:"] }).secret(),
  DEBUG: env.boolean().optional(),
});

schema.keys; // ["PORT", "DATABASE_URL", "DEBUG"]
```

### `isEnvSchema(value)`

Type guard that recognises a schema by its `kind` property. Works even across separate copies of the module.

```ts
function isEnvSchema(value: unknown): value is EnvSchema;
```

```ts
import { isEnvSchema } from "@envlock/core";

isEnvSchema(schema); // true
isEnvSchema({ kind: "something-else" }); // false
```

## Parsing and Validation

### `parseEnv(schema, source, options?)`

Validates a source record against a schema without throwing. Returns a `ParseResult` discriminated union.

```ts
function parseEnv<S extends EnvSchema>(
  schema: S,
  source: EnvSource,
  options?: ParseOptions,
): ParseResult<S>;
```

`EnvSource` is `Readonly<Record<string, string | undefined>>` -- compatible with `process.env` and the output of `parseDotenv`.

`ParseOptions` accepts a single field:

- `strict` -- when `true`, source keys not declared in the schema produce `unknown` issues.

The result is one of:

- `{ ok: true, values: Infer<S>, issues: readonly [] }` -- all variables are valid.
- `{ ok: false, issues: readonly EnvIssue[] }` -- one or more problems were found.

**Key behaviours:**

- **Empty string is absent.** Both `undefined` and `""` are treated as the variable being unset.
- **Defaults fill in.** An absent variable with `.default(value)` resolves to that default.
- **Optional skips.** An absent variable marked `.optional()` produces no issue and is omitted from the values object.
- **Invalid values.** A present value that fails the field's parser produces an `invalid` issue. The `received` property on the issue contains the raw value (masked for secrets).
- **Strict mode.** With `strict: true`, source keys not in the schema become `unknown` issues, sorted alphabetically after the declared-key issues.
- **Deterministic ordering.** Issues for declared keys appear in declaration order, followed by unknown keys in alphabetical order.

```ts
import { defineEnv, env, parseEnv } from "@envlock/core";

const schema = defineEnv({
  PORT: env.port().default(3000),
  API_KEY: env.string().secret(),
});

const result = parseEnv(schema, { PORT: "not-a-number" });

if (!result.ok) {
  for (const issue of result.issues) {
    console.log(issue.key, issue.code, issue.message);
  }
}
```

### `loadEnv(schema, source?, options?)`

A convenience wrapper around `parseEnv` that returns the typed values directly or throws on failure.

```ts
function loadEnv<S extends EnvSchema>(
  schema: S,
  source?: EnvSource,
  options?: ParseOptions,
): Infer<S>;
```

When `source` is omitted it defaults to `process.env`. If validation fails, an `EnvValidationError` is thrown.

```ts
import { defineEnv, env, loadEnv } from "@envlock/core";

const schema = defineEnv({
  PORT: env.port().default(3000),
  DATABASE_URL: env.url({ protocols: ["postgres:"] }).secret(),
  DEBUG: env.boolean().optional(),
});

const config = loadEnv(schema);
// config: { PORT: number; DATABASE_URL: string; DEBUG?: boolean }
```

You can also pass a custom source to validate a dotenv file without touching `process.env`:

```ts
import { parseDotenv, loadEnv } from "@envlock/core";

const fileSource = parseDotenv(dotenvText);
const config = loadEnv(schema, fileSource);
```

## Error Handling

### `EnvValidationError`

A class extending `Error`, thrown by `loadEnv` when validation fails.

```ts
class EnvValidationError extends Error {
  readonly issues: readonly EnvIssue[];
  constructor(issues: readonly EnvIssue[]);
}
```

The `name` property is `"EnvValidationError"`. The message follows this format:

```
Environment validation failed (N issues):
  - KEY: message (received "value")
  - OTHER_KEY: message
```

The `issues` array contains every problem found, in deterministic order. Each `EnvIssue` has:

- `key` -- the variable name
- `code` -- one of `"missing"`, `"invalid"`, or `"unknown"`
- `message` -- a human-readable description
- `received` -- the raw value (present only for `invalid` issues; masked to `"••••••"` for secrets)

### `formatIssues(issues)`

Produces the indented bullet list used in `EnvValidationError.message`. Useful if you want the same formatting outside of an error context.

```ts
function formatIssues(issues: readonly EnvIssue[]): string;
```

```ts
import { parseEnv, formatIssues } from "@envlock/core";

const result = parseEnv(schema, source);
if (!result.ok) {
  console.error(formatIssues(result.issues));
}
```

## Issue Codes

Every `EnvIssue` carries a `code` field with one of three values:

| Code | Meaning |
|------|---------|
| `missing` | A required variable is not set or is set to an empty string. |
| `invalid` | The value is present but does not satisfy the declared type's parser (e.g., `"abc"` for a `port` field). |
| `unknown` | The variable is present in the source but not declared in the schema. Only reported in strict mode. |

## Constants

### `ISSUE_CODES`

```ts
const ISSUE_CODES = {
  missing: "missing",
  invalid: "invalid",
  unknown: "unknown",
} as const;
```

### `REDACTED_VALUE`

The mask string used for secret values in issues, descriptions, and `redact` output:

```ts
const REDACTED_VALUE = "••••••";
```

Six bullet characters (U+2022).

### `FIELD_KINDS`

An object mapping each field kind name to itself, useful for runtime checks:

```ts
const FIELD_KINDS = {
  string: "string",
  number: "number",
  integer: "integer",
  boolean: "boolean",
  port: "port",
  url: "url",
  enum: "enum",
  json: "json",
  duration: "duration",
  list: "list",
} as const;
```

### `SCHEMA_KIND`

The constant marker used on every `EnvSchema` object:

```ts
const SCHEMA_KIND = "envlock.schema" as const;
```

## Types

The following TypeScript types are exported from `@envlock/core` for use in your own code.

### `FieldKind`

Union of all ten builder kind strings:

`"string" | "number" | "integer" | "boolean" | "port" | "url" | "enum" | "json" | "duration" | "list"`

### `Field<T, Required>`

The interface representing a single field declaration. Carries the `kind`, `isOptional`, `hasDefault`, `isSecret`, and `parse` method, plus the chain methods `.optional()`, `.default()`, `.secret()`, `.describe()`, and `.example()`.

### `AnyField`

Alias for `Field<unknown, boolean>` — used as a constraint in generic signatures like `defineEnv`.

### `FieldValue<F>`

Extracts the value type of a field: `F extends Field<infer T> ? T : never`.

### `ParseOutcome<T>`

The result of a single field's `parse` method:

`{ readonly ok: true; readonly value: T } | { readonly ok: false; readonly message: string }`

### `EnvSchema<Shape>`

The schema object returned by `defineEnv`:

```ts
interface EnvSchema<Shape> {
  readonly kind: "envlock.schema";
  readonly shape: Shape;
  readonly keys: readonly string[];
}
```

### `Infer<S>`

A mapped type that derives the typed values object from a schema. Required and defaulted fields become required properties; `.optional()` fields become optional properties.

### `EnvSource`

`Readonly<Record<string, string | undefined>>` — compatible with `process.env`.

### `EnvDiff`

Returned by `diffEnv`:

```ts
interface EnvDiff {
  readonly missing: readonly string[];
  readonly unknown: readonly string[];
  readonly invalid: readonly EnvIssue[];
  readonly ok: boolean;
}
```

### `SchemaDescription`

Returned by `describeSchema` — see the [describeSchema section](#describeschemaschema) for field details.

### `RenderExampleOptions`

```ts
interface RenderExampleOptions {
  readonly header?: readonly string[];
}
```

## Secret Masking

Fields marked with `.secret()` receive special treatment throughout the library:

- The `received` property in an `EnvIssue` is set to `"••••••"` instead of the actual value.
- `describeSchema` masks secret defaults with `REDACTED_VALUE` and omits secret `exampleValue`.
- `renderExample` renders secret variables as `KEY=` with no value. Secret defaults appear as `(hidden)` in the metadata comment.
- `redact()` replaces every secret field's value with `"••••••"` in the output.

## Projection Functions

### `diffEnv(schema, source)`

Compares a source against a schema with strict mode enabled, partitioning issues into categorised buckets.

```ts
function diffEnv(schema: EnvSchema, source: EnvSource): EnvDiff;
```

The returned `EnvDiff` contains:

- `missing` -- variable names that are required but absent (array of strings)
- `unknown` -- variable names present in the source but not in the schema (array of strings)
- `invalid` -- issues for values that failed parsing (array of `EnvIssue`)
- `ok` -- `true` when all three arrays are empty

```ts
import { diffEnv, parseDotenv } from "@envlock/core";

const source = parseDotenv(dotenvText);
const diff = diffEnv(schema, source);

if (!diff.ok) {
  console.log("Missing:", diff.missing);
  console.log("Unknown:", diff.unknown);
  console.log("Invalid:", diff.invalid.map((i) => i.key));
}
```

### `redact(values, schema)`

Returns a shallow copy of a values object with every `.secret()` field replaced by `"••••••"`. The input is never mutated.

```ts
function redact<T extends Readonly<Record<string, unknown>>>(
  values: T,
  schema: EnvSchema,
): { readonly [K in keyof T]: T[K] | string };
```

```ts
import { loadEnv, redact } from "@envlock/core";

const config = loadEnv(schema);
console.log("Config (safe to log):", redact(config, schema));
```

### `describeSchema(schema)`

Returns a JSON-serialisable array describing every field in declaration order.

```ts
function describeSchema(schema: EnvSchema): SchemaDescription[];
```

Each `SchemaDescription` entry has:

- `key` -- variable name
- `type` -- the `FieldKind` (e.g., `"port"`, `"url"`, `"boolean"`)
- `required` -- whether the field is required
- `hasDefault` -- whether a default is declared
- `default` -- the default value (masked with `REDACTED_VALUE` for secrets)
- `secret` -- whether the field is marked secret
- `description` -- the `.describe()` text, if set
- `example` -- the `.example()` text (omitted for secrets)
- `constraints` -- parser constraints, if any

### `renderExample(schema, options?)`

Renders a `.env.example` file from the schema.

```ts
function renderExample(
  schema: EnvSchema,
  options?: RenderExampleOptions,
): string;
```

`RenderExampleOptions` accepts:

- `header` -- an array of strings used as comment lines at the top. Pass an empty array to omit the header entirely. The default header is:

```
# Environment contract rendered by envlock.
# Copy to .env and fill in the values; never commit real secrets here.
```

For each variable, the output includes:

- An optional `# description` comment (if `.describe()` was used)
- A `# type . required|optional . constraints . secret` metadata comment
- A `KEY=value` assignment line, where the value is the `.example()` text if set, the default value if available, or empty. Secrets always render as `KEY=` regardless of example or default, and secret defaults appear as `(hidden)` in the metadata comment.

```ts
import { renderExample } from "@envlock/core";

const text = renderExample(schema);
console.log(text);
```

## Dotenv Functions

### `parseDotenv(text)`

A dependency-free dotenv parser.

```ts
function parseDotenv(text: string): Record<string, string>;
```

Supported syntax:

- `KEY=value` -- simple key-value pairs
- `export KEY=value` -- the `export` prefix is accepted and stripped
- `# comment` -- lines starting with `#` are ignored
- Single-quoted values -- taken literally with no escape processing
- Double-quoted values -- supports `\n`, `\r`, `\t`, `\"`, `\\`, and `\$` escapes
- Multi-line quoted values -- both single and double quotes can span lines
- Empty values -- `KEY=` sets the key to an empty string
- Inline comments -- a `#` preceded by whitespace ends an unquoted value

CRLF line endings are normalised. Malformed lines are silently skipped. When a key appears more than once, the last value wins. The function never throws.

### `formatDotenv(record)`

Serialises a record as dotenv-formatted text.

```ts
function formatDotenv(record: Readonly<Record<string, string>>): string;
```

Keys are written in sorted order. Values are quoted only when necessary. The round-trip `parseDotenv(formatDotenv(r))` equals `r`. An empty record produces an empty string.

```ts
import { parseDotenv, formatDotenv } from "@envlock/core";

const parsed = parseDotenv('PORT=3000\nDEBUG=true');
const text = formatDotenv(parsed);
// DEBUG=true
// PORT=3000
```