---
title: Defining Schemas
description: Declare typed environment variable contracts with defineEnv, field builders, chain methods, and type utilities.
url: https://pr-1-ff84656b4b8a.thally.app/envlock/defining-schemas
---

# Defining Schemas

Declare typed environment variable contracts with defineEnv, field builders, chain methods, and type utilities.

A schema is the single source of truth for every environment variable your application reads. You define it with `defineEnv` and a set of field builders, then export it from a config file so both your app and the CLI can use it.

## The config file pattern

Create an `envlock.config.mjs` (or `envlock.config.js`) at your project root. The CLI discovers this file automatically.

```js
// envlock.config.mjs
import { defineEnv, env } from "@envlock/core";

export default defineEnv({
  NODE_ENV: env.enum(["development", "production"]).default("development")
    .describe("Runtime environment"),
  PORT: env.port().default(3000).describe("HTTP listen port"),
  DATABASE_URL: env.url({ protocols: ["postgres:", "postgresql:"] })
    .secret()
    .describe("Primary database connection string")
    .example("postgres://user:pass@localhost:5432/mydb"),
  SESSION_SECRET: env.string().secret().describe("Secret key for session signing"),
  REQUEST_TIMEOUT: env.duration().default(30000)
    .describe("HTTP request timeout"),
  ALLOWED_ORIGINS: env.list().default(["http://localhost:3000"])
    .describe("CORS allowed origins"),
  FEATURE_FLAGS: env.json().optional().describe("Optional feature flag overrides"),
});
```

The file must be executable by Node (`.mjs` or `.js`, not `.ts`). It can use a default export or a named `schema` export.

## defineEnv

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

`defineEnv` accepts an object whose keys are variable names and whose values are fields from the `env` builders. It returns a frozen `EnvSchema` with:

- `kind` -- the constant `"envlock.schema"`
- `shape` -- the frozen field map
- `keys` -- a frozen array of variable names in declaration order

Declaration order is preserved in all outputs: validation issues, rendered examples, schema descriptions, and diffs.

## Variable name rules

Every key passed to `defineEnv` must match the pattern `/^[A-Za-z_][A-Za-z0-9_]*$/`. Names must start with a letter or underscore and contain only letters, digits, and underscores.

Passing an invalid name throws a `TypeError` at definition time with the message:

``Invalid environment variable name "<key>": use letters, digits and underscores, not starting with a digit``

## Field builders

The `env` object provides ten builder factories. Each returns a `Field<T, true>` (required, not secret). All parse functions are total and never throw.

| Builder | Output type | Constraints | Parse behavior |
|---|---|---|---|
| `env.string()` | `string` | (none) | Verbatim, no trimming |
| `env.number()` | `number` | `"finite number"` | Trims whitespace, rejects empty strings, `NaN`, and `Infinity` |
| `env.integer()` | `number` | `"whole number"` | Like `number`, plus `Number.isSafeInteger()` check |
| `env.boolean()` | `boolean` | `"true/false, 1/0, yes/no, on/off"` | Case-insensitive accepted words (see below) |
| `env.port()` | `number` | `"integer 1-65535"` | Integer in range 1 through 65535 |
| `env.url(options?)` | `string` | `"absolute URL"` or with protocol list | Parses with `new URL()`; optional protocol allow-list |
| `env.enum(values)` | Literal union of `values` | `"one of: a, b"` | Exact match against member set |
| `env.json<T>()` | `T` (defaults to `unknown`) | `"JSON document"` | `JSON.parse`; syntax errors become `invalid` issues |
| `env.duration()` | `number` (milliseconds) | `"duration like 30s, 5m, 2h, stored as milliseconds"` | Regex-based; bare number treated as milliseconds |
| `env.list(options?)` | `string[]` | `"comma-separated list"` (or custom separator) | Split by separator, trim items, drop empty items |

### Boolean accepted values

The following values are accepted (case-insensitive, trimmed):

| Truthy | Falsy |
|---|---|
| `true` | `false` |
| `1` | `0` |
| `yes` | `no` |
| `on` | `off` |

### Duration units

A duration string is a number followed by an optional unit. A bare number with no unit is treated as milliseconds.

| Unit | Multiplier (ms) |
|---|---|
| `ms` | 1 |
| `s` | 1000 |
| `m` | 60000 |
| `h` | 3600000 |
| `d` | 86400000 |

Examples: `"30s"` becomes 30000, `"5m"` becomes 300000, `"250"` becomes 250.

### URL options

`env.url()` accepts an optional `UrlOptions` object:

```ts
type UrlOptions = { readonly protocols?: readonly string[] }
```

The `protocols` array restricts which URL schemes are accepted. Include the trailing colon in each entry (for example `"https:"`, `"postgres:"`). Without `protocols`, any absolute URL is accepted.

### List options

`env.list()` accepts an optional `ListOptions` object:

```ts
type ListOptions = { readonly separator?: string }
```

The `separator` defaults to `","`. Items are trimmed and empty items are dropped.

## Chain methods

Each chain method returns a new frozen field. The original field is not modified, making it safe to share fields between schemas.

| Method | Signature | Effect |
|---|---|---|
| `.optional()` | `optional(): Field<T, false>` | Absent values produce no issue. The key is omitted from the result. |
| `.default(value)` | `default(value: T): Field<T, true>` | Absent values resolve to `value`. Clears optional status; the field is always present in the result. |
| `.secret()` | `secret(): Field<T, Required>` | Marks the field as secret. Values are masked as `"••••••"` in issues, examples, descriptions, and `redact` output. |
| `.describe(text)` | `describe(text: string): Field<T, Required>` | Sets a human-readable description, shown in rendered examples and `describeSchema` output. |
| `.example(text)` | `example(text: string): Field<T, Required>` | Sets an example value used as the placeholder in `.env.example` output. |

### Field properties

Every field exposes the following read-only properties:

- `kind` -- the builder name (one of `"string"`, `"number"`, `"integer"`, `"boolean"`, `"port"`, `"url"`, `"enum"`, `"json"`, `"duration"`, `"list"`)
- `isOptional` -- whether `.optional()` was called
- `hasDefault` -- whether `.default()` was called
- `defaultValue` -- the default value, only present when `hasDefault` is true
- `isSecret` -- whether `.secret()` was called
- `description` -- the description string, if set
- `exampleValue` -- the example string, if set
- `constraints` -- a human-readable constraint string (builder-specific)
- `parse` -- the parse function, `(raw: string) => ParseOutcome<T>`

`ParseOutcome<T>` is a discriminated union:

```ts
type ParseOutcome<T> =
  | { readonly ok: true; readonly value: T }
  | { readonly ok: false; readonly message: string };
```

## Type utilities

### `EnvSchema<Shape>`

The schema type returned by `defineEnv`.

```ts
interface EnvSchema<Shape extends Record<string, AnyField> = Record<string, AnyField>> {
  readonly kind: typeof SCHEMA_KIND;   // "envlock.schema"
  readonly shape: Shape;
  readonly keys: readonly string[];
}
```

### `SCHEMA_KIND`

The constant string `"envlock.schema"`, used as the `kind` discriminant on every schema.

### `isEnvSchema`

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

Runtime type guard that checks for the correct `kind`, an `Array` `keys` property, and an object `shape` property.

### `Infer<S>`

```ts
type Infer<S extends EnvSchema>
```

Maps a schema to the type of its validated output. Required and defaulted fields become required properties. Fields marked `.optional()` become optional properties.

### `FieldValue<F>`

```ts
type FieldValue<F>
```

Extracts the value type `T` from a `Field<T, boolean>`.

### `AnyField`

```ts
type AnyField = Field<unknown, boolean>
```

The widened field type accepted by `defineEnv` and other schema-level functions.

### `FieldKind`

```ts
type FieldKind = "string" | "number" | "integer" | "boolean"
  | "port" | "url" | "enum" | "json" | "duration" | "list"
```

The union of all builder names.

### `FIELD_KINDS`

A constant object mapping each kind name to itself, useful for runtime checks.

## Complete example

The following config file declares seven variables covering most builder types:

```js
// envlock.config.mjs
import { defineEnv, env } from "@envlock/core";

export default defineEnv({
  NODE_ENV: env.enum(["development", "production"])
    .default("development")
    .describe("Runtime environment"),

  PORT: env.port()
    .default(3000)
    .describe("HTTP listen port"),

  DATABASE_URL: env.url({ protocols: ["postgres:", "postgresql:"] })
    .secret()
    .describe("Primary database connection string")
    .example("postgres://user:pass@localhost:5432/mydb"),

  SESSION_SECRET: env.string()
    .secret()
    .describe("Secret key for session signing"),

  REQUEST_TIMEOUT: env.duration()
    .default(30000)
    .describe("HTTP request timeout"),

  ALLOWED_ORIGINS: env.list()
    .default(["http://localhost:3000"])
    .describe("CORS allowed origins"),

  FEATURE_FLAGS: env.json()
    .optional()
    .describe("Optional feature flag overrides"),
});
```

After defining the schema, use it in your application:

```js
import { loadEnv, redact } from "@envlock/core";
import schema from "./envlock.config.mjs";

const config = loadEnv(schema);
console.log(redact(config, schema));
```

Or validate from the command line:

```sh
envlock check
envlock example --out .env.example
```

See [Validation](/envlock/validation) for how `loadEnv` and `parseEnv` process the schema, and [CLI](/envlock/cli) for all available commands.