---
title: Envlock
description: Typed environment contracts for Node.js — validate, type, document, and diff your env variables.
url: https://pr-1-7abef52380a8.thally.app/envlock-overview
---

# Envlock

Typed environment contracts for Node.js — validate, type, document, and diff your env variables.

Envlock lets you declare the environment variables your app needs once, then
validate, type-check, document, and diff them everywhere — at startup, in CI,
and from coding agents over MCP.

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

// Throws EnvValidationError listing EVERY problem, or returns typed values:
// { PORT: number; DATABASE_URL: string; DEBUG?: boolean }
const config = loadEnv(schema);
```

## Why Envlock

- **One contract, every projection.** `defineEnv` is the single source of truth;
  `parseEnv`, `renderExample`, `diffEnv`, `describeSchema`, and `redact` are all
  derived from it, so `.env.example` can never drift from what the code actually
  reads.
- **Inference, not annotation.** `Infer<typeof schema>` gives you
  `{ PORT: number; DEBUG?: boolean }` for free. `.optional()` fields become
  optional properties; `.default()` fields stay required in the output type.
- **Every issue at once.** `loadEnv` collects all `missing` / `invalid` /
  `unknown` issues before throwing, in deterministic order, so a broken deploy
  shows one complete list instead of one error per restart.
- **Secrets stay secret.** `.secret()` masks values in issue output, in
  `.env.example`, in `describeSchema()`, and in `redact()` — the raw value never
  reaches a log or an agent.
- **Zero runtime dependencies** in `@envlock/core` and `@envlock/cli`. The MCP
  server adds only the official `@modelcontextprotocol/sdk` and `zod`.
- **Agent-native.** `@envlock/mcp` exposes five tools so a coding agent can
  verify env configuration without reading secrets.

## Packages

| Package | Description | Runtime dependencies |
| --- | --- | --- |
| [`@envlock/core`](/envlock-core-api) | Schema builders, `parseEnv`/`loadEnv`, dotenv parser, `.env.example` renderer, diff, redact, and describe. | none |
| [`@envlock/cli`](/envlock-cli) | The `envlock` command: `check`, `example`, `diff`, `inspect`, `init`. | `@envlock/core` |
| [`@envlock/mcp`](/envlock-mcp) | The `envlock-mcp` stdio MCP server with five tools and a schema resource. | `@envlock/core`, `@modelcontextprotocol/sdk`, `zod` |

All packages are ESM, TypeScript-first, and require Node.js 22 or later.

## Install

```bash
# Runtime validation in your app
npm install @envlock/core

# CLI for local checks and CI
npm install -D @envlock/cli

# MCP server for coding agents (optional)
npm install -D @envlock/mcp
```

## How validation works

1. **Iteration order is declaration order.** `defineEnv` records keys in the
   order you wrote them; every projection follows it, so output is stable across
   runs and diffs cleanly in git.
2. **Absent means `undefined` or `""`.** An empty value such as `KEY=` counts
   as unset. A `.default()` supplies the value, `.optional()` skips it,
   otherwise a `missing` issue is recorded.
3. **Present values go through the field's parser.** Parsers are total
   functions: they return `{ ok: true, value }` or `{ ok: false, message }` and
   never throw. A failure becomes an `invalid` issue carrying the raw `received`
   value — masked as `"••••••"` for `.secret()` fields.
4. **Unknown keys are opt-in.** With `strict: true`, keys present in the source
   but absent from the schema become `unknown` issues. Strict mode is only
   meaningful for bounded sources such as a parsed `.env` file; against
   `process.env` every shell variable would be flagged.
5. **`parseEnv` is the only validator.** `loadEnv`, `diffEnv`, the CLI, and the
   MCP tools all call it. There is exactly one definition of missing, invalid,
   and unknown.

## FAQ

#### Does Envlock load .env files into process.env?

  No. `parseDotenv(text)` returns a plain record and `loadEnv(schema, source)`
  accepts any record, so you decide how to compose sources:
  `loadEnv(schema, { ...parseDotenv(text), ...process.env })`. Envlock never
  mutates `process.env`.

#### Why is an empty string treated as missing?

  Because `KEY=` in a dotenv file almost always means "fill me in". Treating it
  as a real empty string would let placeholder files pass validation. If you need
  to allow blanks, mark the field `.optional()`.

#### Can I use the config file from TypeScript?

  The CLI and MCP server import the config with a dynamic `import()`, so it must
  be something Node can execute directly: `.mjs` or `.js`. In your own code you
  can call `defineEnv` from any `.ts` file and get full inference.

#### How do I share the schema between the app and the CLI?

  Put it in `envlock.config.mjs`, `export default` it, and
  `import schema from "./envlock.config.mjs"` in your app. The CLI, the MCP
  server, and `loadEnv` all consume the same object.

#### What does an agent see when it calls envlock_check?

  Only issue metadata: key, code, message, and — for non-secret fields — the
  received raw value. Secret values are masked, and `envlock_inspect` masks
  secret defaults as well.