first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
# create-env
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createEnv` is the primary public API of `@suaveplan/env`. It orchestrates the complete lifecycle of environment loading: merging values from one or more loaders, overlaying a direct source object, running schema validation, handling errors according to the configured mode, and returning a frozen, fully-typed record. The return type is inferred from the schema at compile time, giving callers precise TypeScript types without any manual annotation.
## Features
- Accepts any combination of `loaders` and a direct `source` (later sources take priority)
- Validates all fields accumulating every error before surfacing failures
- Three error modes: `'throw'` (default) raises `EnvValidationError` with the full list; `'collect'` returns partial data; `'warn'` logs each failure via `console.warn` and returns partial data
- Optional `prefix` strips a common prefix from schema key lookups (e.g. `'APP_'` looks up `APP_PORT` for schema key `PORT`)
- Returns a `Readonly<InferEnv<S>>` frozen record — mutation after construction throws at runtime
- Sensitive field values are available in the returned record; use `maskSensitive` before logging
## Usage: Basic Example
```ts
import { createEnv, str, num, bool } from '@suaveplan/env';
const env = createEnv({
schema: {
HOST: str().default('localhost'),
PORT: num().default(3000),
DEBUG: bool().default(false),
},
source: process.env,
});
// TypeScript knows env.HOST is string, env.PORT is number, env.DEBUG is boolean
console.log(env.HOST); // "localhost" (or value from process.env)
console.log(env.PORT); // 3000
console.log(env.DEBUG); // false
```
## Usage: Advanced Example
```ts
import { createEnv, str, num } from '@suaveplan/env';
import { dotenvLoader } from '@suaveplan/env/loaders';
import { objectLoader } from '@suaveplan/env/loaders';
const env = createEnv({
schema: {
DB_URL: str().sensitive(),
PORT: num().default(8080),
},
// Load from .env file first, then override with process.env
loaders: [
dotenvLoader({ path: '.env' }),
objectLoader({ source: process.env }),
],
// Additionally read from a specific object (highest priority)
source: { PORT: '9000' },
// Look up APP_DB_URL and APP_PORT in the merged values
prefix: 'APP_',
// Collect errors instead of throwing — useful in test helpers
onError: 'collect',
});
```
## API Reference
### `createEnv<S extends EnvSchema>(options: CreateEnvOptions<S>): Readonly<InferEnv<S>>`
**`CreateEnvOptions<S>` properties:**
| Property | Type | Default | Description |
|---|---|---|---|
| `schema` | `S` | required | Record of field builders |
| `source` | `Record<string, string \| undefined>?` | `undefined` | Direct value map (e.g. `process.env`); overrides loaders |
| `loaders` | `ReadonlyArray<Loader>?` | `[]` | Ordered loaders; later loaders override earlier ones |
| `prefix` | `string?` | `undefined` | Key prefix stripped during lookup |
| `onError` | `'throw' \| 'collect' \| 'warn'` | `'throw'` | Error handling mode |
**Returns:** `Readonly<InferEnv<S>>` — a frozen object with types derived from the schema.
**Throws:** `EnvValidationError` when `onError === 'throw'` and any field fails validation.
## Implementation Notes
`buildValues` merges loader results in order (later loaders win), then spreads the `source` object on top. This means `source` always takes priority over any loader, which mirrors how `process.env` should override `.env` file defaults.
The `validateInput` object is built conditionally (`prefix ? { schema, values, prefix } : { schema, values }`) to satisfy TypeScript's `exactOptionalPropertyTypes` setting, which disallows assigning `string | undefined` to an `optional string` property.
`Object.freeze` is applied to the result data record before returning. The freeze is shallow — nested objects within JSON fields are not recursively frozen — but it prevents accidental mutation of top-level keys.
The `'warn'` mode uses `maskSensitive` before logging each error's raw value so that secrets in failed coercions do not appear in console output. The `'collect'` mode returns partial data silently, relying on the caller to inspect `result.errors` via a separate call if needed. Because `createEnv` returns only the data record, the collect mode's errors are only visible if the caller switches to a wrapper that calls `validate` directly.

View File

@@ -0,0 +1,97 @@
# field
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines `BaseField<T, TRequired>`, the abstract base class that all environment field builders extend. It centralises the shared state and behaviour every field needs — required/optional tracking, default values, sensitivity markers, human-readable descriptions, and custom validation rules — so that concrete field classes only have to implement one method: `coerce(raw: string): T`. The phantom type parameter `TRequired` flows through the fluent builder chain to produce compile-time required/optional distinctions in the `InferEnv<S>` mapped type.
## Features
- Required/optional tracking with `_markRequired()` / `_markOptional()` protected helpers
- Default value support via `_setDefault(value)` — automatically marks the field as required once a default is set
- Sensitive flag via `sensitive()` — instructs `maskSensitive` to redact the value before logging
- Human-readable descriptions via `describe(text)` — surfaced in error messages and documentation tools
- Custom validation rules via `addRule(options)` — rules run in order after `coerce()` succeeds; first failure short-circuits per field
- `getConfig()` extracts a snapshot of all state as a plain `FieldConfig<T>` object for use by the validator and diagnostics
- Phantom `_phantom` field carries `{ type: T; required: TRequired }` for structural type inference without any runtime overhead
## Usage: Basic Example
```ts
import { str } from '@suaveplan/env';
// str() returns a StringField which extends BaseField
const field = str().default('localhost').sensitive().describe('Server host');
// Inspect the config
const config = field.getConfig();
console.log(config.isRequired); // true (default sets required)
console.log(config.hasDefault); // true
console.log(config.defaultValue); // "localhost"
console.log(config.isSensitive); // true
console.log(config.description); // "Server host"
```
## Usage: Advanced Example
```ts
import { num } from '@suaveplan/env';
import { BaseField } from '@suaveplan/env/core/field/field';
// Add custom validation rules directly to any field
const portField = num().addRule({
name: 'allowedPorts',
message: 'Must be 80, 443, or 8080',
validate: (v) => [80, 443, 8080].includes(v),
});
// Implement a custom field by extending BaseField
class HexColorField extends BaseField<string> {
coerce(raw: string): string {
const trimmed = raw.trim();
if (!/^#[0-9a-fA-F]{6}$/.test(trimmed)) {
throw new Error(`"${trimmed}" is not a valid hex colour`);
}
return trimmed.toLowerCase();
}
required() { this._markRequired(); return this as HexColorField<true>; }
optional() { this._markOptional(); return this as HexColorField<false>; }
default(v: string) { this._setDefault(v); return this as HexColorField<true>; }
}
```
## API Reference
### `BaseField<T, TRequired extends boolean = true>` (abstract class)
| Member | Type | Description |
|---|---|---|
| `_isRequired` | `boolean` | Whether the field must be present or have a default |
| `_hasDefault` | `boolean` | Whether a default value has been set |
| `_defaultValue` | `T \| undefined` | The default value (if set) |
| `_isSensitive` | `boolean` | Whether the value should be redacted in logs |
| `_description` | `string \| undefined` | Human-readable field description |
| `_rules` | `Array<ValidationRule<T>>` | Ordered list of post-coercion rules |
| `sensitive()` | `this` | Mark the field as carrying sensitive data |
| `describe(text)` | `this` | Attach a description string |
| `addRule(options)` | `this` | Append a custom `ValidationRule<T>` |
| `getConfig()` | `FieldConfig<T>` | Snapshot of all field state |
| `coerce(raw)` | `T` | Abstract — must be implemented by subclasses |
### Protected helpers
| Method | Description |
|---|---|
| `_markRequired()` | Sets `_isRequired = true`, clears `_hasDefault` |
| `_markOptional()` | Sets `_isRequired = false` |
| `_setDefault(value)` | Sets `_hasDefault = true`, stores the value, sets `_isRequired = true` |
## Implementation Notes
The `declare readonly _phantom` field uses `declare` to emit no runtime property while still participating in TypeScript's structural type system. This allows the `InferEnv` mapped type to distinguish `AnyField<T, true>` from `AnyField<T, false>` when computing required vs optional output keys, without any storage cost.
`_setDefault` intentionally keeps `_isRequired = true` after setting a default. A field with a default always resolves — it is never undefined in the output — so it must satisfy the required type constraint in `InferEnv`. Calling `optional()` after `default()` would override this to produce an optional output key, which is an unusual but valid use case.
The `ValidationRule<T>` interface deliberately uses method syntax (`validate(value: T): boolean`) rather than a property function type (`validate: (value: T) => boolean`). Method syntax is bivariant in TypeScript, which allows a `StringField` (whose rules have type `ValidationRule<string>`) to be assigned to `AnyField<unknown>` without a variance error.

View File

@@ -0,0 +1,108 @@
# validator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the core `validate` function that processes an entire env schema against a raw values map, accumulating all errors before returning. A complete error report — rather than fail-fast — is the defining characteristic of this module: developers see every broken field in a single run rather than fixing one variable and re-running to discover the next failure. The complementary `maskSensitive` function redacts sensitive field values so the parsed record can be logged safely.
## Features
- Accumulates errors across all fields before returning — no fail-fast behaviour
- Per-field processing: absence check → default resolution → coercion → rule chain
- Stops rule evaluation at the first failing rule per field (fail-fast within a field, accumulate across fields)
- Optional key prefix: looks up `${prefix}${schemaKey}` in the values map while using `schemaKey` in the output
- Graceful handling of non-`Error` coercion throws — produces `"Coercion failed"` rather than crashing
- `maskSensitive` replaces values of fields marked `_isSensitive` with `"****"`, leaving non-sensitive keys untouched
- Returns a `ValidationResult` with a boolean `success` flag, the typed `data` record, and the `errors` array
## Usage: Basic Example
```ts
import { validate } from '@suaveplan/env/core/validator/validator';
import { str, num } from '@suaveplan/env';
const result = validate({
schema: { HOST: str(), PORT: num() },
values: { HOST: 'localhost', PORT: '3000' },
});
if (result.success) {
console.log(result.data.HOST); // "localhost"
console.log(result.data.PORT); // 3000
} else {
for (const err of result.errors) {
console.error(`${err.key}: ${err.message}`);
}
}
```
## Usage: Advanced Example
```ts
import { validate, maskSensitive } from '@suaveplan/env/core/validator/validator';
import { str, num, bool } from '@suaveplan/env';
const schema = {
DB_URL: str().sensitive(),
PORT: num().default(3000),
DEBUG: bool().default(false),
};
// Prefix lookup: reads APP_DB_URL, APP_PORT, APP_DEBUG from values
const result = validate({
schema,
values: { APP_DB_URL: 'postgres://user:pass@host/db' },
prefix: 'APP_',
});
// Mask before logging
const safe = maskSensitive(schema, result.data);
console.log(safe.DB_URL); // "****"
console.log(safe.PORT); // 3000
console.log(safe.DEBUG); // false
```
## API Reference
### `validate(input: ValidateInput): ValidationResult`
Validates all fields in the schema against the values map.
**Parameters (`ValidateInput`):**
| Property | Type | Description |
|---|---|---|
| `schema` | `EnvSchema` | Record of field builders keyed by env variable name |
| `values` | `Record<string, string \| undefined>` | Raw key→value map to look up values from |
| `prefix` | `string?` | Optional prefix prepended to each schema key during lookup |
**Returns (`ValidationResult`):**
| Property | Type | Description |
|---|---|---|
| `success` | `boolean` | `true` if no errors were accumulated |
| `data` | `Record<string, unknown>` | Coerced values for all fields that resolved successfully |
| `errors` | `EnvError[]` | All per-field errors, in schema iteration order |
### `maskSensitive(schema, data): Record<string, unknown>`
Returns a shallow copy of `data` with sensitive field values replaced by `"****"`.
**Parameters:**
| Parameter | Type | Description |
|---|---|---|
| `schema` | `Record<string, AnyField>` | The same schema passed to `validate` |
| `data` | `Record<string, unknown>` | The parsed data record from `validate` |
**Returns:** A new record; the original `data` is not mutated. Keys not present in `schema` are passed through unchanged.
## Implementation Notes
`processField` is a private helper that handles the resolution order for a single field: check for absence, check for default, attempt coercion, run rules. The rule loop uses an early `return` on the first failure, so only one error per field reaches the accumulator. This avoids confusing multi-error noise for a single field while still giving the full cross-field picture.
The `err instanceof Error ? err.message : "Coercion failed"` branch in the catch block handles the edge case where a `coerce` implementation throws a non-`Error` value (e.g. a string literal). This is not a normal case for built-in fields but is possible for custom field implementations.
`maskSensitive` iterates `data` rather than `schema` so that extra keys that happen to be in `data` but not in `schema` are preserved. It checks `field?._isSensitive` with optional chaining to handle that case gracefully.

View File

@@ -0,0 +1,106 @@
# env-error
## Purpose
Provides the complete error class hierarchy and a value-object factory for `@suaveplan/env`. All classes extend `SuaveplanError` from `@suaveplan/error` via the `EnvBaseError` abstract base, ensuring every thrown error carries a stable machine-readable `ENV.*` code, a human-readable message, and optional cause chaining. Structured error classes make it possible for callers to programmatically distinguish between a missing variable, a coercion failure, a format violation, and a dotenv parse error without string-matching.
## Features
- `EnvBaseError` abstract base typed to the `EnvCode` union — all concrete classes extend this
- `EnvValidationError` — aggregates multiple per-field errors into a single throw; exposes `errors: ReadonlyArray<EnvError>` for structured inspection
- `MissingEnvError` — carries the `key` string of the absent required variable
- `CoercionError` — carries `key`, `rawValue`, and `targetType` for precise diagnostic messages
- `DotenvParseError` — carries `lineNumber` for easy file-navigation on parse failures
- `createEnvError` — plain factory producing the `EnvError` value-object interface shape used by the validator (no class instantiation needed for accumulation)
## Usage: Basic Example
```ts
import {
EnvValidationError,
MissingEnvError,
CoercionError,
DotenvParseError,
createEnvError,
} from '@suaveplan/env/errors';
// Thrown automatically by createEnv when onError === 'throw'
try {
throw new EnvValidationError([
{ key: 'PORT', message: 'Required variable is missing' },
]);
} catch (err) {
if (err instanceof EnvValidationError) {
for (const e of err.errors) {
console.error(`${e.key}: ${e.message}`);
}
}
}
```
## Usage: Advanced Example
```ts
import { CoercionError, createEnvError } from '@suaveplan/env/errors';
// Build a value-object array to pass to EnvValidationError
const errors = [];
// createEnvError returns a plain object — used internally by the validator
errors.push(createEnvError('HOST', 'Required variable is missing'));
errors.push(createEnvError('PORT', 'Cannot coerce "abc" to number', 'abc'));
// CoercionError carries structured fields for programmatic use
const err = new CoercionError('PORT', 'abc', 'number');
console.log(err.code); // "ENV.COERCE_ERROR"
console.log(err.key); // "PORT"
console.log(err.rawValue); // "abc"
console.log(err.targetType); // "number"
```
## API Reference
### `EnvBaseError` (abstract class)
Extends `SuaveplanError<EnvCode>`. All env error classes extend this. Not directly instantiable.
### `EnvValidationError`
`constructor(errors: ReadonlyArray<EnvError>, options?: SuaveplanErrorOptions)`
- `errors` — structured list of per-field failures, also available as `err.errors`
- Code: `ENV.VALIDATION_FAILED`
- Message: multiline listing each failing field
### `MissingEnvError`
`constructor(key: string, options?: SuaveplanErrorOptions)`
- `key` — name of the missing environment variable, also available as `err.key`
- Code: `ENV.MISSING_REQUIRED`
### `CoercionError`
`constructor(key: string, rawValue: string, targetType: string, options?: SuaveplanErrorOptions)`
- `key` — variable name (may be empty string when thrown from a coerce function)
- `rawValue` — the string that could not be converted
- `targetType` — description of the expected type (e.g. `"number"`, `"URL"`, `"one of: a, b"`)
- Code: `ENV.COERCE_ERROR`
### `DotenvParseError`
`constructor(message: string, lineNumber: number, options?: SuaveplanErrorOptions)`
- `lineNumber` — 1-based line number in the dotenv file where the error occurred
- Code: `ENV.PARSE_ERROR`
### `createEnvError(key, message, value?)`
Returns `EnvError` (plain interface, not a class). Conditionally includes `value` only when defined to satisfy `exactOptionalPropertyTypes`. Used internally by the validator to accumulate errors without class instantiation overhead.
## Implementation Notes
The abstract base class is named `EnvBaseError` rather than `EnvError` to avoid a duplicate-identifier conflict with the `EnvError` interface exported from `types/common.ts`. The `EnvError` interface represents a plain value object `{ key, message, value? }` used for accumulation, while `EnvBaseError` (and its subclasses) are throwable classes.
`createEnvError` uses a conditional return (`if (value !== undefined)`) to avoid assigning `string | undefined` to the optional `value` property under TypeScript's `exactOptionalPropertyTypes` mode, which disallows explicit `undefined` assignments to optional fields.

View File

@@ -0,0 +1,69 @@
# error-codes
## Purpose
Defines and registers all stable error codes for the `@suaveplan/env` package using the `defineCodes` factory from `@suaveplan/error`. Grouping codes under the `ENV` namespace ensures they are globally unique across the entire project and prevents collisions with error codes from other packages. Every concrete error class in this package uses one of these codes.
## Features
- Five registered codes under the `ENV` namespace: `VALIDATION_FAILED`, `MISSING_REQUIRED`, `INVALID_FORMAT`, `PARSE_ERROR`, `COERCE_ERROR`
- Fully literal-inferred return type — the TypeScript type of each code is its exact string value, not `string`
- `EnvCode` union type derived from the registry for use in function signatures and switch statements
- Zero runtime overhead beyond the single `defineCodes` call at module initialisation
## Usage: Basic Example
```ts
import { ENV_CODES, type EnvCode } from '@suaveplan/env/errors';
// Access a specific code
console.log(ENV_CODES.VALIDATION_FAILED); // "ENV.VALIDATION_FAILED"
console.log(ENV_CODES.MISSING_REQUIRED); // "ENV.MISSING_REQUIRED"
console.log(ENV_CODES.COERCE_ERROR); // "ENV.COERCE_ERROR"
```
## Usage: Advanced Example
```ts
import { ENV_CODES, type EnvCode } from '@suaveplan/env/errors';
// Use EnvCode in a type-safe switch
function describe(code: EnvCode): string {
switch (code) {
case ENV_CODES.VALIDATION_FAILED:
return 'One or more fields failed validation';
case ENV_CODES.MISSING_REQUIRED:
return 'A required variable was absent';
case ENV_CODES.INVALID_FORMAT:
return 'The value did not match the expected format';
case ENV_CODES.PARSE_ERROR:
return 'The dotenv file contained malformed syntax';
case ENV_CODES.COERCE_ERROR:
return 'The raw string could not be coerced to the target type';
}
}
```
## API Reference
### `ENV_CODES`
`Record<string, string>` — object mapping each code name to its namespaced string value.
| Name | Value |
|---|---|
| `VALIDATION_FAILED` | `"ENV.VALIDATION_FAILED"` |
| `MISSING_REQUIRED` | `"ENV.MISSING_REQUIRED"` |
| `INVALID_FORMAT` | `"ENV.INVALID_FORMAT"` |
| `PARSE_ERROR` | `"ENV.PARSE_ERROR"` |
| `COERCE_ERROR` | `"ENV.COERCE_ERROR"` |
### `EnvCode`
`type EnvCode = (typeof ENV_CODES)[keyof typeof ENV_CODES]`
String union of all registered ENV error code values. Use this as the type for `code` parameters in functions that accept or return env errors.
## Implementation Notes
`defineCodes` from `@suaveplan/error` takes a namespace prefix and a `const` tuple of code names. It returns an object whose values are `"${namespace}.${name}"` strings with each value typed as a string literal, so `typeof ENV_CODES.VALIDATION_FAILED` is `"ENV.VALIDATION_FAILED"` rather than `string`. The `as const` assertion on the array is required to trigger literal inference. The `EnvCode` type is derived from the object rather than declared independently to ensure it stays in sync automatically as codes are added or removed.

View File

@@ -0,0 +1,91 @@
# array-field
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ArrayField` handles environment variables that encode multiple values in a single string by separating them with a delimiter (most commonly a comma). It delegates element-level coercion to any other field builder, enabling typed arrays of strings, numbers, booleans, URLs, or any custom field type. This composition pattern — wrapping an existing field — keeps `ArrayField` focused on splitting and filtering while the item field handles type coercion.
## Features
- Splits on a configurable separator (default: `","`)
- Trims whitespace from each item by default (disable with `noTrim()`)
- Filters empty strings after splitting by default (disable with `keepEmpty()`)
- `minItems(n)` / `maxItems(n)` — validate the resulting array length
- Item-level coercion and validation errors bubble up from the item field's `coerce()` call
- `optional()` / `default(value: T[])` / `sensitive()` inherited from `BaseField`
- The output type `T[]` is inferred from the item field's generic parameter
## Usage: Basic Example
```ts
import { array, str } from '@suaveplan/env';
const field = array(str());
console.log(field.coerce('a,b,c')); // ["a", "b", "c"]
console.log(field.coerce(' a , b , c ')); // ["a", "b", "c"] (trimmed)
console.log(field.coerce('')); // []
```
## Usage: Advanced Example
```ts
import { createEnv, array, str, num, url } from '@suaveplan/env';
const env = createEnv({
schema: {
ALLOWED_HOSTS: array(str())
.minItems(1)
.default(['localhost']),
REDIS_PORTS: array(num().integer().min(1).max(65535))
.separator(':')
.default([6379]),
CORS_ORIGINS: array(url().protocols(['https', 'http']))
.minItems(1)
.maxItems(10),
// Semicolon-separated, keeping empty strings
RAW_TAGS: array(str()).separator(';').noTrim().keepEmpty(),
},
source: process.env,
});
// env.ALLOWED_HOSTS: string[]
// env.REDIS_PORTS: number[]
// env.CORS_ORIGINS: string[]
```
## API Reference
### `array<T>(itemField: AnyField<T>): ArrayField<T, true>`
Factory function. Takes any field builder as the item coercer and returns an `ArrayField<T, true>`.
### `class ArrayField<T, TRequired extends boolean = true>`
Extends `BaseField<T[], TRequired>`.
| Method | Returns | Description |
|---|---|---|
| `separator(sep)` | `this` | Override the split delimiter (default `","`) |
| `noTrim()` | `this` | Disable whitespace trimming of items |
| `keepEmpty()` | `this` | Retain empty strings after splitting |
| `minItems(n)` | `this` | Add a minimum array length rule |
| `maxItems(n)` | `this` | Add a maximum array length rule |
| `required()` | `ArrayField<T, true>` | Mark as required |
| `optional()` | `ArrayField<T, false>` | Mark as optional |
| `default(value: T[])` | `ArrayField<T, true>` | Set a default array (already-coerced values) |
### `coerce(raw: string): T[]`
Splits on `_separator`, optionally trims each part, optionally filters empty parts, then maps each non-empty part through `_itemField.coerce()`. Returns an empty array `[]` when the input is entirely empty after splitting and filtering.
**Throws:** Propagates any `CoercionError` thrown by `_itemField.coerce()` for individual items.
## Implementation Notes
`coerce` returns an empty array for empty input rather than throwing. This is correct because the field may be required but validly set to an empty string; `minItems(1)` is the mechanism to enforce non-empty arrays. An absent (undefined) required `ArrayField` still produces a "missing required" error — the empty array shortcut only applies when the raw value is present but blank.
`default()` takes an already-coerced `T[]` value. The default bypasses `coerce` and goes directly into the output, which is consistent with all other field builders. `minItems` and `maxItems` rules are not applied to the default value.
Item coercion errors are not caught by `ArrayField.coerce` — they propagate up to the `processField` function in `validator.ts`, which records them as field errors. The `key` in the resulting `CoercionError` will be the schema key of the `ArrayField`, not an item index, which is a known limitation when diagnosing which element failed.

View File

@@ -0,0 +1,84 @@
# boolean-field
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`BooleanField` converts environment variable strings to JavaScript `boolean` values using configurable sets of truthy and falsy tokens. Environment variables rarely contain `"true"` or `"false"` verbatim; operators use `"1"`, `"yes"`, `"on"`, `"enabled"`, and their opposites. `BooleanField` recognises all of these by default and allows the caller to replace the token sets entirely when a project uses a different convention.
## Features
- Default truthy tokens: `"true"`, `"1"`, `"yes"`, `"on"`, `"enabled"`
- Default falsy tokens: `"false"`, `"0"`, `"no"`, `"off"`, `"disabled"`, `""`
- Case-insensitive comparison — input is lowercased before lookup
- `truthy(values)` — replace the entire set of truthy tokens
- `falsy(values)` — replace the entire set of falsy tokens
- Throws `CoercionError` with a descriptive message listing all accepted tokens when the input matches neither set
- `optional()` / `default(value)` / `sensitive()` inherited from `BaseField`
## Usage: Basic Example
```ts
import { bool } from '@suaveplan/env';
const field = bool().default(false);
console.log(field.coerce('true')); // true
console.log(field.coerce('1')); // true
console.log(field.coerce('yes')); // true
console.log(field.coerce('FALSE')); // false (case insensitive)
console.log(field.coerce('off')); // false
console.log(field._defaultValue); // false
```
## Usage: Advanced Example
```ts
import { createEnv, bool } from '@suaveplan/env';
const env = createEnv({
schema: {
DEBUG: bool().default(false),
// Custom token sets for a legacy system that uses Y/N
FEATURE_FLAG: bool()
.truthy(['Y', 'YES', 'TRUE'])
.falsy(['N', 'NO', 'FALSE'])
.default(false),
},
source: process.env,
});
// env.DEBUG and env.FEATURE_FLAG are both typed as boolean
```
## API Reference
### `bool(): BooleanField<true>`
Factory function. Returns a new `BooleanField` with the default truthy/falsy sets and `_isRequired = true`.
### `class BooleanField<TRequired extends boolean = true>`
Extends `BaseField<boolean, TRequired>`.
| Method | Returns | Description |
|---|---|---|
| `truthy(values)` | `this` | Replace the truthy token set (lowercased internally) |
| `falsy(values)` | `this` | Replace the falsy token set (lowercased internally) |
| `required()` | `BooleanField<true>` | Mark as required |
| `optional()` | `BooleanField<false>` | Mark as optional |
| `default(value)` | `BooleanField<true>` | Set a boolean default |
### `coerce(raw: string): boolean`
Trims and lowercases the input, then checks `_truthyValues.has(lower)` and `_falsyValues.has(lower)`. Returns `true`, `false`, or throws.
**Throws:** `CoercionError` listing all accepted tokens when neither set matches.
## Implementation Notes
Internally `_truthyValues` and `_falsyValues` are `Set<string>` instances for O(1) lookup, initialised from the `TRUTHY_VALUES` and `FALSY_VALUES` readonly arrays defined in `constants/index.ts`. The `truthy()` and `falsy()` methods replace the sets entirely rather than merging — this prevents accidental accumulation of tokens when chaining and makes the configuration explicit.
The error message in `CoercionError` includes `[...this._truthyValues, ...this._falsyValues].join(", ")` so the user sees exactly which tokens are accepted. This is more helpful than a generic `"expected boolean"` message, especially when custom token sets are in use.
The `coerce` function in `transforms/coerce/coerce.ts` implements a simpler boolean coercion (using array `.includes()` on the constants) for use outside of `BooleanField`. `BooleanField.coerce` always uses the instance's `Set` members, so custom token sets specified via `truthy()`/`falsy()` are respected.

View File

@@ -0,0 +1,84 @@
# email-field
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`EmailField` validates that an environment variable contains a syntactically plausible email address before returning it as a string. It uses a regex pattern that covers the vast majority of real-world email addresses (<local-part@domain.tld>) without the complexity of full RFC 5321 compliance, striking a practical balance between correctness and simplicity. It is the appropriate field to use for `SMTP_FROM`, notification sender addresses, and any other configuration that requires a contact email.
## Features
- Validates against a built-in `EMAIL_PATTERN` regex covering standard email formats
- `normalise()` — lowercases the entire address before returning (useful for case-insensitive comparison later)
- `pattern(regex)` — swap in a custom validation regex when stricter or looser checking is needed
- `optional()` / `default(value)` / `sensitive()` inherited from `BaseField`
- Throws `CoercionError` with `targetType = "email address"` for invalid inputs
## Usage: Basic Example
```ts
import { email } from '@suaveplan/env';
const field = email();
console.log(field.coerce('Admin@Example.COM')); // "Admin@Example.COM"
const normalised = email().normalise();
console.log(normalised.coerce('Admin@Example.COM')); // "admin@example.com"
```
## Usage: Advanced Example
```ts
import { createEnv, email } from '@suaveplan/env';
const env = createEnv({
schema: {
SMTP_FROM: email()
.normalise()
.describe('Default sender address for outgoing mail'),
SUPPORT_EMAIL: email()
.optional()
.describe('Support contact email (optional)'),
// Strict internal-only email: must end with @company.com
ADMIN_EMAIL: email()
.pattern(/^[^@]+@company\.com$/)
.describe('Admin email — must be a company address'),
},
source: process.env,
});
// env.SMTP_FROM: string, env.SUPPORT_EMAIL: string | undefined
```
## API Reference
### `email(): EmailField<true>`
Factory function. Returns a new `EmailField` with the default `EMAIL_PATTERN` and `_isRequired = true`.
### `class EmailField<TRequired extends boolean = true>`
Extends `BaseField<string, TRequired>`.
| Method | Returns | Description |
|---|---|---|
| `pattern(regex)` | `this` | Replace the validation regex |
| `normalise()` | `this` | Lowercase the value during coercion |
| `required()` | `EmailField<true>` | Mark as required |
| `optional()` | `EmailField<false>` | Mark as optional |
| `default(value)` | `EmailField<true>` | Set a default email address |
### `coerce(raw: string): string`
Trims the input, optionally lowercases, then tests against `_pattern`. Returns the (possibly normalised) value on match.
**Throws:** `CoercionError` with `targetType = "email address"` when the pattern test fails.
## Implementation Notes
The `EMAIL_PATTERN` constant is defined in `constants/index.ts` as a regex that matches `local@domain.tld` with reasonable permissiveness — it allows plus signs, dots, and hyphens in the local part but does not support quoted local parts or IP-literal hosts. This covers `>99%` of real-world addresses in practice.
The `normalise()` option lowercases the entire address. Email addresses are technically case-sensitive in the local part (before `@`) per RFC 5321, but essentially all major mail services treat the local part as case-insensitive in practice. Normalising avoids duplicates when addresses are stored or compared downstream.
Normalisation occurs before pattern testing, so a custom `pattern()` receives the lowercased string when `normalise()` is also set. This means patterns can rely on lowercase input when the two options are combined.

View File

@@ -0,0 +1,85 @@
# enum-field
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`EnumField` constrains an environment variable to a fixed set of allowed string values and infers the output type as the string-literal union of those values. This is the correct field to use when only a small number of known strings are valid — for example `NODE_ENV` or `LOG_LEVEL` — because TypeScript tracks which specific strings are permitted at compile time and the runtime check ensures the running process cannot use an undeclared value.
## Features
- Infers the output type as a string-literal union: `enums(['a', 'b'])` produces `EnumField<"a" | "b">`
- Strict exact-match coercion by default; unrecognised values throw `CoercionError` listing all allowed strings
- `caseInsensitive()` — normalises both the input and the allowed values to lowercase before comparing
- `getAllowedValues()` — returns the readonly tuple of allowed values for documentation and tooling
- `default(value: T)` — the default must be one of the allowed values (enforced by the `T` type)
- `optional()` / `sensitive()` inherited from `BaseField`
## Usage: Basic Example
```ts
import { enums } from '@suaveplan/env';
const field = enums(['development', 'production', 'test', 'staging'] as const)
.default('development');
console.log(field.coerce('production')); // "production"
console.log(field._defaultValue); // "development"
// TypeScript type: EnumField<"development" | "production" | "test" | "staging">
```
## Usage: Advanced Example
```ts
import { createEnv, enums } from '@suaveplan/env';
const env = createEnv({
schema: {
NODE_ENV: enums(['development', 'production', 'test', 'staging'] as const)
.default('development'),
LOG_LEVEL: enums(['trace', 'debug', 'info', 'warn', 'error', 'fatal'] as const)
.default('info'),
// Case-insensitive: 'PROD', 'prod', 'Prod' all match 'production'
REGION: enums(['us-east-1', 'eu-west-1', 'ap-southeast-1'] as const)
.caseInsensitive()
.default('us-east-1'),
},
source: process.env,
});
// env.NODE_ENV: "development" | "production" | "test" | "staging"
// env.LOG_LEVEL: "trace" | "debug" | "info" | "warn" | "error" | "fatal"
```
## API Reference
### `enums<const T extends readonly string[]>(values: T): EnumField<T[number], true>`
Factory function. Takes a const tuple of allowed strings and returns an `EnumField` typed to the union of those strings.
### `class EnumField<T extends string, TRequired extends boolean = true>`
Extends `BaseField<T, TRequired>`.
| Method | Returns | Description |
|---|---|---|
| `caseInsensitive()` | `this` | Enable case-insensitive matching |
| `getAllowedValues()` | `ReadonlyArray<T>` | The original allowed values array |
| `required()` | `EnumField<T, true>` | Mark as required |
| `optional()` | `EnumField<T, false>` | Mark as optional |
| `default(value: T)` | `EnumField<T, true>` | Set a default (must be an allowed value) |
### `coerce(raw: string): T`
Trims input. In case-insensitive mode, lowercases both input and each allowed value before comparing; returns the original casing of the matching allowed value. Throws `CoercionError` listing all allowed values when no match is found.
**Throws:** `CoercionError` with `targetType = "one of: a, b, c"` when the input is not in the allowed set.
## Implementation Notes
The `enums` factory uses `const T extends readonly string[]` to capture the literal types from a `const` tuple. Without the `const` modifier the type degrades to `string[]` and literal inference is lost. The `as const` assertion on the passed array is required from the call site for the same reason.
Case-insensitive mode returns the original casing of the matched allowed value (`return allowed`) rather than the lowercased input. This ensures consistent output regardless of input casing — useful when the value is later used as a dictionary key or log string where the canonical casing matters.
`getAllowedValues()` is primarily useful for generating documentation, CLI `--help` output, or OpenAPI schema `enum` arrays without duplicating the literal list.

View File

@@ -0,0 +1,90 @@
# json-field
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`JsonField` handles environment variables whose values are serialised JSON documents. This pattern is used for feature flag objects, CORS origins arrays, TLS certificate configuration, and any other structured value that does not fit a scalar field. The field parses the raw string with `JSON.parse` and optionally applies a type-guard function to narrow the TypeScript type of the parsed result from `unknown` to the caller's expected shape.
## Features
- Parses raw strings via `JSON.parse` and throws `CoercionError` on malformed JSON
- `validator(fn)` — attach a runtime type guard to narrow from `unknown` to `T`; throws `CoercionError` on guard failure
- Type parameter `T` defaults to `unknown`; supply it explicitly when using a validator for full type safety
- `optional()` / `default(value: T)` / `sensitive()` inherited from `BaseField`
## Usage: Basic Example
```ts
import { json } from '@suaveplan/env';
const field = json<string[]>();
console.log(field.coerce('["a","b","c"]')); // ["a", "b", "c"]
console.log(field.coerce('not json')); // throws CoercionError
```
## Usage: Advanced Example
```ts
import { createEnv, json } from '@suaveplan/env';
interface FeatureFlags {
darkMode: boolean;
betaUsers: number;
allowedRegions: string[];
}
const isFeatureFlags = (v: unknown): v is FeatureFlags =>
typeof v === 'object' &&
v !== null &&
'darkMode' in v &&
'betaUsers' in v &&
'allowedRegions' in v;
const env = createEnv({
schema: {
FEATURE_FLAGS: json<FeatureFlags>()
.validator(isFeatureFlags)
.default({ darkMode: false, betaUsers: 0, allowedRegions: [] }),
CORS_ORIGINS: json<string[]>()
.validator((v): v is string[] => Array.isArray(v))
.default(['http://localhost:3000']),
},
source: process.env,
});
// env.FEATURE_FLAGS is typed as FeatureFlags
// env.CORS_ORIGINS is typed as string[]
```
## API Reference
### `json<T = unknown>(): JsonField<T, true>`
Factory function. Returns a new `JsonField` typed to `T` with `_isRequired = true`.
### `class JsonField<T = unknown, TRequired extends boolean = true>`
Extends `BaseField<T, TRequired>`.
| Method | Returns | Description |
|---|---|---|
| `validator(fn)` | `this` | Set a runtime type guard `(v: unknown) => v is T` |
| `required()` | `JsonField<T, true>` | Mark as required |
| `optional()` | `JsonField<T, false>` | Mark as optional |
| `default(value: T)` | `JsonField<T, true>` | Set a default parsed value (not a JSON string) |
### `coerce(raw: string): T`
Trims input and calls `JSON.parse`. If a validator is set and returns `false`, throws a second `CoercionError`. Returns the parsed value cast to `T`.
**Throws:** `CoercionError` with `targetType = "JSON"` on parse failure; `targetType = "JSON (failed validation)"` when the validator returns `false`.
## Implementation Notes
The `default()` method takes a typed `T` value (already parsed), not a JSON string. This means the default bypasses `coerce` entirely — the validator is not applied to defaults. If shape validation of defaults is required, apply it at schema definition time.
The validator function is typed as `(v: unknown) => v is T` (a user-defined type guard). TypeScript does not enforce that the guard actually validates the full shape at compile time — the `is T` annotation is a programmer assertion. Callers should write guards that actually check the runtime structure to avoid false type safety.
`json()` intentionally leaves the default type parameter as `unknown` to avoid false confidence when no validator is supplied. Using `json()` without a validator returns `unknown` at the type level, which forces callers to add their own type checks before use.

View File

@@ -0,0 +1,82 @@
# number-field
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`NumberField` handles environment variables that must be interpreted as JavaScript `number` values. It uses `Number()` coercion for maximum compatibility with decimal, integer, and scientific notation strings, then provides optional constraint rules for range checking, integer enforcement, and sign enforcement. Unlike the generic `num()` builder, the `port()` and similar specialised builders delegate to `NumberField` internally, composing it with additional validation logic.
## Features
- Coerces raw strings to `number` via `Number()`, rejecting empty strings and `NaN` with `CoercionError`
- `min(n)` / `max(n)` — enforce inclusive numeric bounds
- `integer()` — require the value to be a whole number (`Number.isInteger`)
- `positive()` — require the value to be strictly greater than zero
- `nonNegative()` — require the value to be greater than or equal to zero
- `optional()` — marks the field as not required; output type becomes `number | undefined`
- `default(value)` — sets a fallback number; keeps the output type as `number`
- All constraint rules compose additively; add as many as needed
## Usage: Basic Example
```ts
import { num } from '@suaveplan/env';
const field = num().min(1).max(65535).integer().default(3000);
console.log(field.coerce('8080')); // 8080
console.log(field.coerce('3.14')); // 3.14 (passes coerce, fails integer rule)
```
## Usage: Advanced Example
```ts
import { createEnv, num } from '@suaveplan/env';
const env = createEnv({
schema: {
PORT: num().integer().min(1024).max(65535).default(3000),
TIMEOUT_MS: num().positive().integer().default(5000),
SCALE_FACTOR: num().nonNegative().default(1.0),
WORKERS: num().integer().positive().default(4),
},
source: process.env,
});
// env.PORT: number, env.TIMEOUT_MS: number, etc.
```
## API Reference
### `num(): NumberField<true>`
Factory function. Returns a new `NumberField` with `_isRequired = true`.
### `class NumberField<TRequired extends boolean = true>`
Extends `BaseField<number, TRequired>`.
| Method | Returns | Description |
|---|---|---|
| `min(n)` | `this` | Add a minimum value rule (inclusive) |
| `max(n)` | `this` | Add a maximum value rule (inclusive) |
| `integer()` | `this` | Add an integer constraint rule |
| `positive()` | `this` | Add a strictly-positive constraint rule |
| `nonNegative()` | `this` | Add a non-negative constraint rule |
| `required()` | `NumberField<true>` | Mark as required |
| `optional()` | `NumberField<false>` | Mark as optional |
| `default(value)` | `NumberField<true>` | Set a numeric default |
### `coerce(raw: string): number`
Trims the input and runs `Number()`. Throws `CoercionError` if the trimmed string is empty or `Number.isNaN(result)` is true. Accepts decimal, integer, and scientific notation (e.g. `"1e3"` → `1000`).
**Throws:** `CoercionError` with `targetType = "number"` on empty or non-numeric input.
## Implementation Notes
An explicit empty-string check comes before `Number()` because `Number("")` returns `0`, which would silently accept empty values. The NaN check uses `Number.isNaN` (the global version returns false for non-numbers, but the Number method is preferred for clarity).
`integer()`, `positive()`, and `nonNegative()` are implemented as ordinary validation rules rather than coercion logic because they run after the value is already a `number`. This means the error messages reference the coerced value and the constraint name, which is more informative than a generic coercion failure message.
Combining `min`, `max`, `integer`, and `positive` rules is fully additive. The validator stops at the first failing rule per field, so the order of rule addition determines which constraint's error message appears when multiple rules fail.

View File

@@ -0,0 +1,78 @@
# port-field
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PortField` validates that an environment variable is a valid TCP/UDP port number — an integer in the range 1–65535. It is a specialised numeric field that combines integer enforcement and range checking directly in `coerce`, providing a single focused field for `PORT`, `SMTP_PORT`, `REDIS_PORT`, and similar network configuration variables. The acceptable range can be narrowed via `min()` and `max()` when only a subset of port numbers is appropriate.
## Features
- Coerces raw strings to `number` with NaN and non-integer rejection via `CoercionError`
- Default range: 1–65535 (full valid TCP/UDP port range)
- `min(n)` / `max(n)` — narrow the acceptable range (e.g. 1024–65535 for unprivileged ports only)
- Throws `CoercionError` with a human-readable range in the `targetType` message for out-of-range values
- `optional()` / `default(value)` / `sensitive()` inherited from `BaseField`
## Usage: Basic Example
```ts
import { port } from '@suaveplan/env';
const field = port().default(3000);
console.log(field.coerce('8080')); // 8080
console.log(field.coerce('0')); // throws CoercionError (below range)
console.log(field.coerce('65536')); // throws CoercionError (above range)
console.log(field.coerce('3.14')); // throws CoercionError (not integer)
```
## Usage: Advanced Example
```ts
import { createEnv, port } from '@suaveplan/env';
const env = createEnv({
schema: {
PORT: port().default(3000),
SMTP_PORT: port().min(1).max(65535).default(587),
// Restrict to unprivileged ports only
HTTP_PORT: port().min(1024).default(8080),
},
source: process.env,
});
// env.PORT, env.SMTP_PORT, env.HTTP_PORT are all typed as number
```
## API Reference
### `port(): PortField<true>`
Factory function. Returns a new `PortField` with range `[PORT_MIN, PORT_MAX]` (1–65535) and `_isRequired = true`.
### `class PortField<TRequired extends boolean = true>`
Extends `BaseField<number, TRequired>`.
| Method | Returns | Description |
|---|---|---|
| `min(n)` | `this` | Set the minimum allowed port number |
| `max(n)` | `this` | Set the maximum allowed port number |
| `required()` | `PortField<true>` | Mark as required |
| `optional()` | `PortField<false>` | Mark as optional |
| `default(value)` | `PortField<true>` | Set a numeric default port |
### `coerce(raw: string): number`
Trims the input, runs `Number()`, checks `Number.isNaN` and `Number.isInteger`, then verifies the value is within `[_min, _max]`.
**Throws:** `CoercionError` with `targetType = "port number"` for NaN or non-integer input, and `targetType = "port number (MIN–MAX)"` for out-of-range values.
## Implementation Notes
Range validation occurs in `coerce` rather than as a post-coercion rule (unlike `NumberField.min/max`). This is an intentional design choice: port validity is definitional, not a secondary constraint. The error message includes the actual range bounds so the user sees `"port number (1–65535)"` rather than a generic failure.
`PORT_MIN` and `PORT_MAX` are defined in `constants/index.ts` as `1` and `65535`. System ports (0) are excluded — port 0 is a wildcard in socket APIs and would be confusing as a static configuration value.
The `min()` and `max()` methods mutate `_min` and `_max` directly rather than appending rules, keeping the error message consistent (it always shows the actual current bounds). If rules were used instead, adding a `min()` and `max()` would produce two separate rule messages that could conflict with the base range check.

View File

@@ -0,0 +1,97 @@
# string-field
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`StringField` is the most general-purpose field builder in `@suaveplan/env`, handling any environment variable whose value is a plain string. It extends `BaseField<string>` and adds optional whitespace trimming, case transformation, length constraints, regex pattern matching, and substring checks — all composable via a fluent builder chain. The raw environment string passes through `coerce` with no conversion beyond the configured transformations; all structural validation is done via rules applied after coercion.
## Features
- `trim()` — strip leading and trailing whitespace before validation
- `lowercase()` / `uppercase()` — normalise case before returning the value
- `minLength(n)` / `maxLength(n)` — enforce character count bounds
- `pattern(regex, message?)` — require the value to match a regular expression
- `nonempty()` — shorthand for `minLength(1)` with a clear error message
- `startsWith(prefix)` / `endsWith(suffix)` / `includes(substring)` — substring constraint rules
- `optional()` — marks the field as not required; output type becomes `string | undefined`
- `default(value)` — sets a fallback string; keeps the output type as `string`
- `sensitive()` — inherited from `BaseField`; redacts value in logs via `maskSensitive`
## Usage: Basic Example
```ts
import { str } from '@suaveplan/env';
const field = str().trim().default('localhost');
console.log(field.coerce(' example.com ')); // "example.com"
console.log(field._defaultValue); // "localhost"
console.log(field._hasDefault); // true
```
## Usage: Advanced Example
```ts
import { createEnv, str } from '@suaveplan/env';
const env = createEnv({
schema: {
APP_NAME: str()
.trim()
.minLength(1)
.maxLength(64)
.pattern(/^[a-z][a-z0-9-]*$/, 'Must be a lowercase slug')
.describe('Application name'),
API_KEY: str()
.nonempty()
.sensitive()
.describe('API authentication key'),
REGION: str()
.uppercase()
.default('US')
.describe('Deployment region code'),
},
source: process.env,
});
// env.APP_NAME is string, env.API_KEY is string, env.REGION is string
```
## API Reference
### `str(): StringField<true>`
Factory function. Returns a new `StringField` instance with `_isRequired = true`.
### `class StringField<TRequired extends boolean = true>`
Extends `BaseField<string, TRequired>`.
| Method | Returns | Description |
|---|---|---|
| `trim()` | `this` | Trim whitespace during coercion |
| `lowercase()` | `this` | Lowercase the value during coercion |
| `uppercase()` | `this` | Uppercase the value during coercion |
| `minLength(n)` | `this` | Add a min-length validation rule |
| `maxLength(n)` | `this` | Add a max-length validation rule |
| `pattern(regex, msg?)` | `this` | Add a regex match rule |
| `nonempty()` | `this` | Add a non-empty rule |
| `startsWith(prefix)` | `this` | Add a startsWith rule |
| `endsWith(suffix)` | `this` | Add an endsWith rule |
| `includes(substring)` | `this` | Add an includes rule |
| `required()` | `StringField<true>` | Mark as required (returns new type) |
| `optional()` | `StringField<false>` | Mark as optional (returns new type) |
| `default(value)` | `StringField<true>` | Set a default value |
| `coerce(raw)` | `string` | Apply transformations and return the value |
### `coerce(raw: string): string`
Applies configured transformations in order: trim (if enabled) → lowercase (if enabled) → uppercase (if enabled). Does not throw — string coercion cannot fail.
## Implementation Notes
Transformations (`trim`, `lowercase`, `uppercase`) occur inside `coerce` before any rules run. This means a `minLength` rule counts characters after transformation, which is the intuitive behaviour: `str().trim().minLength(1).coerce(' ')` fails because the trimmed value is empty.
The `includes`, `startsWith`, and `endsWith` rules all use the native `String` methods of the same names. They operate on the post-coercion value, so they respect the trim/case transformations. Rules are appended to `_rules` and run by the validator in insertion order; the first failing rule produces the error message.

View File

@@ -0,0 +1,89 @@
# url-field
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`UrlField` validates that an environment variable contains a well-formed URL by delegating to the platform's native `URL` constructor. Using the native parser ensures complete RFC 3986 compliance without any third-party dependency and handles edge cases like percent-encoding, IPv6 literals, and IDN hostnames correctly. It is the right field to use for `DATABASE_URL`, `REDIS_URL`, `API_BASE_URL`, `WEBHOOK_ENDPOINT`, and any other connection or service URL.
## Features
- Validates URL structure via the native `URL` constructor; throws `CoercionError` on parse failure
- `protocols(list)` — restrict the URL to a specific allowlist of protocols (e.g. `['https']`)
- `noTrailingSlash()` — strip a trailing slash from the resolved URL string before returning
- Returns the `URL.href` normalised string (e.g. adds trailing slash to bare origins per the WHATWG URL spec)
- `optional()` / `default(value)` / `sensitive()` inherited from `BaseField`
## Usage: Basic Example
```ts
import { url } from '@suaveplan/env';
const field = url();
console.log(field.coerce('https://example.com/path')); // "https://example.com/path"
console.log(field.coerce('not a url')); // throws CoercionError
const httpsOnly = url().protocols(['https']);
console.log(httpsOnly.coerce('http://example.com')); // throws CoercionError
```
## Usage: Advanced Example
```ts
import { createEnv, url } from '@suaveplan/env';
const env = createEnv({
schema: {
API_BASE_URL: url()
.protocols(['https'])
.noTrailingSlash()
.describe('Base URL for the external API'),
DATABASE_URL: url()
.protocols(['postgres', 'postgresql'])
.sensitive()
.describe('Primary database connection URL'),
REDIS_URL: url()
.protocols(['redis', 'rediss'])
.sensitive()
.optional(),
},
source: process.env,
});
// env.API_BASE_URL: string (no trailing slash, https only)
// env.DATABASE_URL: string (sensitive, postgres only)
// env.REDIS_URL: string | undefined
```
## API Reference
### `url(): UrlField<true>`
Factory function. Returns a new `UrlField` with no protocol restriction and `_isRequired = true`.
### `class UrlField<TRequired extends boolean = true>`
Extends `BaseField<string, TRequired>`.
| Method | Returns | Description |
|---|---|---|
| `protocols(list)` | `this` | Restrict to an allowlist of protocols |
| `noTrailingSlash()` | `this` | Remove a trailing slash from the result |
| `required()` | `UrlField<true>` | Mark as required |
| `optional()` | `UrlField<false>` | Mark as optional |
| `default(value)` | `UrlField<true>` | Set a default URL string |
### `coerce(raw: string): string`
Trims the input and passes it to `new URL()`. If `protocols()` was called, checks `parsed.protocol` against the allowlist. If `noTrailingSlash()` was set and `parsed.href` ends with `"/"`, removes the trailing character.
**Throws:** `CoercionError` with `targetType = "URL"` on parse failure; `targetType = "URL with protocol X or Y"` when the protocol is not in the allowlist.
## Implementation Notes
`protocols()` normalises each entry by appending `":"` if absent (`"https"` → `"https:"`), because `URL.protocol` always includes the colon. This means callers can pass either `"https"` or `"https:"` and get consistent behaviour.
`noTrailingSlash()` strips the trailing character unconditionally when it ends with `"/"`. There is no special case for the `"://"` separator — by the time we check for a trailing slash, the URL is already parsed and `href` only ends with `"/"` when the path itself ends with one (e.g. bare origins like `"https://example.com/"` or paths like `"https://example.com/api/"`). The WHATWG URL parser always adds a trailing slash to bare origins, so `noTrailingSlash()` is the correct way to get `"https://example.com"` from `"https://example.com"`.
The returned value is `parsed.href` (the normalised canonical string), not the original trimmed input. This means URLs are normalised — percent-encoding is resolved, IDN hostnames may be punycode-converted, etc. — which is generally the desired behaviour for configuration values.

View File

@@ -0,0 +1,98 @@
# dotenv
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides two exports for working with `.env` files: `parseDotenv`, a pure function that converts dotenv-format content strings into key-value records, and `dotenvLoader`, a Node.js loader that reads a file path and wraps `parseDotenv`. Separating IO from parsing allows the parser to be tested exhaustively without touching the filesystem and reused in contexts where the content arrives from a non-filesystem source (network, database, secrets manager).
## Features
- `parseDotenv(content)` — pure, synchronous, no IO; fully deterministic for the same input
- Handles double-quoted values with escape sequences: `\n`, `\r`, `\t`, `\"`, `\\`
- Handles single-quoted values literally (no escape processing)
- Strips `export` prefixes from variable declarations
- Ignores comment lines (`#`) and blank lines
- Strips inline comments from unquoted values (`KEY=value # comment` → `value`)
- Handles `CRLF` and `LF` line endings
- Supports `${VAR}` and `$VAR` variable interpolation using earlier-defined variables in the same file
- Skips lines without `=` and lines with an empty key
- `dotenvLoader(options?)` — reads the file via `readFileSync`; returns empty values when the file does not exist
## Usage: Basic Example
```ts
import { parseDotenv } from '@suaveplan/env/loaders';
const content = `
# Database configuration
DB_HOST=localhost
DB_PORT=5432
DB_NAME="my_app"
DB_URL=postgres://${'{'}DB_HOST{'}'}:${'{'}DB_PORT{'}'}/DB_NAME
`;
const values = parseDotenv(content);
// { DB_HOST: 'localhost', DB_PORT: '5432', DB_NAME: 'my_app', DB_URL: 'postgres://localhost:5432/DB_NAME' }
```
## Usage: Advanced Example
```ts
import { createEnv, str, num } from '@suaveplan/env';
import { dotenvLoader } from '@suaveplan/env/loaders';
import { objectLoader } from '@suaveplan/env/loaders';
const env = createEnv({
schema: {
DB_URL: str().sensitive(),
PORT: num().default(3000),
},
loaders: [
// Read from .env.local first (developer overrides)
dotenvLoader({ path: '.env.local', encoding: 'utf8' }),
// Then .env (committed defaults)
dotenvLoader({ path: '.env' }),
// process.env takes final priority (set via objectLoader or source)
objectLoader({ source: process.env }),
],
});
```
## API Reference
### `parseDotenv(content: string): Record<string, string>`
Parses a dotenv-format string and returns a plain object mapping variable names to string values.
- Processes lines in order; later definitions override earlier ones
- Supports `${VAR}` and `$VAR` interpolation against variables defined earlier in the same content string
- Skips lines without `=`, lines with an empty key (`=value`), blank lines, and `#` comment lines
- Strips `export` prefix from variable names
### `dotenvLoader(options?: DotenvLoaderOptions): Loader`
Returns a `Loader` instance backed by the given file path.
**`DotenvLoaderOptions`:**
| Property | Type | Default | Description |
|---|---|---|---|
| `path` | `string` | `".env"` | Path to the dotenv file |
| `encoding` | `BufferEncoding` | `"utf8"` | File encoding for `readFileSync` |
**`Loader` properties:**
- `name` — the file path (used in diagnostics)
- `load()` — returns `{ values: Record<string, string>, source: string }`. Returns `{ values: {}, source: path }` when the file does not exist (ENOENT is silently swallowed; other errors propagate).
## Implementation Notes
This file intentionally imports `node:fs` (`readFileSync`). It is the only module in the package permitted to use Node.js built-ins — all other modules are runtime-environment agnostic. The `no-disallowed` cross-cutting test explicitly allowlists this file and would fail if any other module imported `node:fs`.
`parseDotenv` builds an interpolation context incrementally as it parses each line, so `${EARLIER}` tokens are resolved against variables seen earlier in the same file but `${LATER}` tokens remain unresolved. This matches the behaviour of dotenv implementations in other ecosystems.
Double-quoted escape sequences are processed with a `replace` call using a character-by-character regex: `\\n` → newline, `\\r` → carriage return, `\\t` → tab, `\\"` → double quote, `\\\\` → backslash. Unrecognised escape sequences are passed through unchanged.
Single-quoted values are returned literally — no escape processing occurs — consistent with POSIX shell single-quoting semantics.

View File

@@ -0,0 +1,76 @@
# object (loader)
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`objectLoader` wraps a plain JavaScript object — most commonly `process.env` — in the `Loader` interface so it can participate in the ordered loader chain passed to `createEnv`. It filters out entries whose value is `undefined` (which `process.env` produces for variables that are set but empty vs. absent), converting the rest into a clean `Record<string, string>` that the validator can process without null-checking.
## Features
- Accepts any `Record<string, string | undefined>` as the source, including `process.env`
- Filters out `undefined` values — only string-valued entries are included in the result
- Optional `sourceName` — overrides the `name` and `source` fields for diagnostic messages
- Synchronous and allocation-cheap: iterates the source entries once per `load()` call
- Returns `{ values, source }` conforming to the `LoaderResult` interface
## Usage: Basic Example
```ts
import { objectLoader } from '@suaveplan/env/loaders';
const loader = objectLoader({ source: process.env, sourceName: 'process.env' });
const { values } = loader.load();
// values contains only string-valued entries from process.env
console.log(values.PATH); // e.g. "/usr/local/bin:/usr/bin:/bin"
```
## Usage: Advanced Example
```ts
import { createEnv, str, num } from '@suaveplan/env';
import { objectLoader } from '@suaveplan/env/loaders';
import { dotenvLoader } from '@suaveplan/env/loaders';
const env = createEnv({
schema: {
HOST: str().default('localhost'),
PORT: num().default(3000),
},
loaders: [
// Load defaults from .env file
dotenvLoader({ path: '.env' }),
// Override with the actual process environment (highest priority)
objectLoader({
source: process.env,
sourceName: 'process.env',
}),
],
});
```
## API Reference
### `objectLoader(options: ObjectLoaderOptions): Loader`
**`ObjectLoaderOptions`:**
| Property | Type | Default | Description |
|---|---|---|---|
| `source` | `Record<string, string \| undefined>` | required | The object to read values from |
| `sourceName` | `string?` | `"object"` | Human-readable source name for diagnostics |
**Returns:** A `Loader` with:
- `name` — `options.sourceName ?? "object"`
- `load()` — returns `{ values: Record<string, string>, source: name }` with `undefined` entries excluded
## Implementation Notes
The `undefined` filter exists because `process.env` on Node.js represents the distinction between `KEY=` (set, empty string) and an absent variable as `""` vs. `undefined`. The loader preserves empty-string values — which is intentional — while dropping `undefined` entries, so the downstream validator can apply required-field checks correctly.
`load()` creates a new object on every call by iterating `Object.entries(options.source)`. This is intentional: `process.env` can change between calls (via child process inheritance or test-environment manipulation), and a stale snapshot would give incorrect results. For performance-critical hot paths where `load()` is called frequently, consider capturing the result once at startup via `createEnv`.
The `sourceName` defaults to `"object"` — a deliberately generic string. Callers should always supply a meaningful name (e.g. `"process.env"`, `"test-overrides"`) to make diagnostic messages from `createEnv`'s `warn` mode legible.

View File

@@ -0,0 +1,89 @@
# common (presets)
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides pre-configured field factory functions and a composite `commonFields` bundle for the environment variables that appear in virtually every server application. Using these factories instead of re-declaring fields from scratch ensures consistent defaults, constraints, and descriptions across all services in the project. They serve as the starting point for a schema before application-specific fields are added.
## Features
- `nodeEnvField()` — `NODE_ENV` enum with four allowed values, defaulting to `'development'`
- `portField()` — port number field defaulting to `3000`
- `hostField()` — string field defaulting to `'0.0.0.0'`
- `debugField()` — boolean field defaulting to `false`
- `logLevelField()` — log level enum (six standard levels) defaulting to `'info'`
- `timezoneField()` — IANA timezone string defaulting to `'UTC'`
- `appNameField()` — required application identifier string (1–64 chars)
- `appVersionField()` — semver-like version string defaulting to `'0.0.0'`
- `workersField()` — positive integer worker count defaulting to `1`
- `commonFields` — composite bundle of `NODE_ENV`, `PORT`, `HOST`, `DEBUG`, `LOG_LEVEL`
## Usage: Basic Example
```ts
import { createEnv } from '@suaveplan/env';
import { commonFields } from '@suaveplan/env/presets/common/common';
// Use the bundle directly for the five most common fields
const env = createEnv({
schema: commonFields,
source: process.env,
});
// env.NODE_ENV: "development" | "production" | "test" | "staging"
// env.PORT: number
// env.HOST: string
// env.DEBUG: boolean
// env.LOG_LEVEL: "trace" | "debug" | "info" | "warn" | "error" | "fatal"
```
## Usage: Advanced Example
```ts
import { createEnv, str } from '@suaveplan/env';
import {
commonFields,
appNameField,
appVersionField,
timezoneField,
workersField,
} from '@suaveplan/env/presets/common/common';
const env = createEnv({
schema: {
// Spread the five-field bundle
...commonFields,
// Add application-specific fields from the preset library
APP_NAME: appNameField(),
APP_VERSION: appVersionField(),
TZ: timezoneField(),
WORKERS: workersField(),
// Add custom fields
FEATURE_XYZ: str().optional(),
},
source: process.env,
});
```
## API Reference
| Export | Field Type | Default | Constraints |
|---|---|---|---|
| `nodeEnvField()` | `EnumField` | `'development'` | `development \| production \| test \| staging` |
| `portField()` | `PortField` | `3000` | `1–65535` |
| `hostField()` | `StringField` | `'0.0.0.0'` | none |
| `debugField()` | `BooleanField` | `false` | standard truthy/falsy |
| `logLevelField()` | `EnumField` | `'info'` | `trace \| debug \| info \| warn \| error \| fatal` |
| `timezoneField()` | `StringField` | `'UTC'` | none |
| `appNameField()` | `StringField` | required | `minLength(1), maxLength(64)` |
| `appVersionField()` | `StringField` | `'0.0.0'` | none |
| `workersField()` | `NumberField` | `1` | `integer, positive` |
| `commonFields` | composite | — | `NODE_ENV, PORT, HOST, DEBUG, LOG_LEVEL` |
## Implementation Notes
Each factory function returns a fresh field builder instance on every call. This is important: `commonFields` is a `const` object whose field values are created at module initialisation time, which means the same instance is shared by all users of `commonFields`. If you need to add rules to a preset field, call the factory directly (`portField().min(1024)`) rather than mutating `commonFields.PORT`, which would affect all consumers of the module-level object.
All factories call into the same field builder classes (`str`, `num`, `bool`, `port`, `enums`) that are available in the public API, so there is no special treatment — presets are just convenient defaults, not magic.

View File

@@ -0,0 +1,108 @@
# server (presets)
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides pre-configured field factory functions and a compact `serverFields` bundle for the environment variables commonly required by server-side services — database connections, Redis, SMTP, CORS, session secrets, and rate limiting. All connection credential fields are marked `sensitive()` by default, so they are automatically redacted by `maskSensitive` before values appear in logs. Using these factories ensures consistent naming, protocol restrictions, and security defaults across every service.
## Features
- `databaseUrlField()` — `DATABASE_URL` restricted to postgres/mysql/mongodb protocols, sensitive
- `redisUrlField()` — `REDIS_URL` restricted to redis/rediss protocols, sensitive
- `sessionSecretField()` — `SESSION_SECRET` requiring at least 32 characters, sensitive
- `corsOriginField()` — `CORS_ORIGIN` string defaulting to `'*'`
- `smtpHostField()` / `smtpPortField()` — mail server host and port (default 587)
- `smtpUserField()` / `smtpPassField()` — SMTP credentials, both sensitive
- `smtpFromField()` — email field for the default sender address
- `rateLimitMaxField()` — positive integer defaulting to `100`
- `rateLimitWindowField()` — positive integer (milliseconds window) defaulting to `60`
- `trustProxyField()` — boolean defaulting to `false`
- `serverFields` — compact composite bundle of five core server fields
## Usage: Basic Example
```ts
import { createEnv } from '@suaveplan/env';
import { serverFields } from '@suaveplan/env/presets/server/server';
const env = createEnv({
schema: serverFields,
source: process.env,
});
// env.DATABASE_URL: string (sensitive — redacted in logs)
// env.CORS_ORIGIN: string
// env.TRUST_PROXY: boolean
// env.RATE_LIMIT_MAX: number
// env.RATE_LIMIT_WINDOW: number
```
## Usage: Advanced Example
```ts
import { createEnv, str } from '@suaveplan/env';
import { commonFields } from '@suaveplan/env/presets/common/common';
import {
databaseUrlField,
redisUrlField,
sessionSecretField,
smtpHostField,
smtpPortField,
smtpUserField,
smtpPassField,
smtpFromField,
corsOriginField,
rateLimitMaxField,
rateLimitWindowField,
trustProxyField,
} from '@suaveplan/env/presets/server/server';
import { dotenvLoader } from '@suaveplan/env/loaders';
const env = createEnv({
schema: {
...commonFields,
DATABASE_URL: databaseUrlField(),
REDIS_URL: redisUrlField().optional(),
SESSION_SECRET: sessionSecretField(),
CORS_ORIGIN: corsOriginField(),
SMTP_HOST: smtpHostField().optional(),
SMTP_PORT: smtpPortField(),
SMTP_USER: smtpUserField().optional(),
SMTP_PASS: smtpPassField().optional(),
SMTP_FROM: smtpFromField().optional(),
RATE_LIMIT_MAX: rateLimitMaxField(),
RATE_LIMIT_WINDOW: rateLimitWindowField(),
TRUST_PROXY: trustProxyField(),
},
loaders: [dotenvLoader()],
source: process.env,
});
```
## API Reference
| Export | Field Type | Default | Notable constraints |
|---|---|---|---|
| `databaseUrlField()` | `UrlField` | required | protocols: postgres, postgresql, mysql, mongodb; sensitive |
| `redisUrlField()` | `UrlField` | required | protocols: redis, rediss; sensitive |
| `sessionSecretField()` | `StringField` | required | `minLength(32)`, sensitive |
| `corsOriginField()` | `StringField` | `'*'` | none |
| `smtpHostField()` | `StringField` | required | none |
| `smtpPortField()` | `NumberField` | `587` | `integer, min 1, max 65535` |
| `smtpUserField()` | `StringField` | required | sensitive |
| `smtpPassField()` | `StringField` | required | sensitive |
| `smtpFromField()` | `EmailField` | required | valid email |
| `rateLimitMaxField()` | `NumberField` | `100` | `integer, positive` |
| `rateLimitWindowField()` | `NumberField` | `60` | `integer, positive` |
| `trustProxyField()` | `BooleanField` | `false` | none |
| `serverFields` | composite | — | DATABASE_URL, CORS_ORIGIN, TRUST_PROXY, RATE_LIMIT_MAX, RATE_LIMIT_WINDOW |
## Implementation Notes
As with the `common` presets, each factory function returns a fresh field builder instance on every call. Mutations (e.g. adding rules via `.addRule()`) on a factory return value do not affect other consumers.
`serverFields` provides only five fields — the minimal set required by most applications — rather than all twelve. SMTP, session, and Redis fields are omitted from the bundle because they are optional in many services. Import the individual factories and compose a custom schema when the full set is needed.
`sessionSecretField` enforces `minLength(32)` because shorter secrets are vulnerable to brute-force attacks in HMAC-based session signing. Production deployments should use cryptographically random secrets of at least 64 characters (256 bits of entropy).

View File

@@ -0,0 +1,94 @@
# env.schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines Zod v4 schemas for the runtime-facing data structures of `@suaveplan/env`. These schemas enable structured validation of external data — API payloads, configuration files, interop boundaries — where the shape of a `FieldConfig`, `ValidationResult`, or `CreateEnvOptions` cannot be guaranteed at the TypeScript type level alone. They also serve as the canonical source of truth for serialisation formats when env configuration is persisted or transmitted.
## Features
- `FieldConfigSchema` — validates the runtime snapshot of a field builder's state
- `ValidationRuleSchema` — validates the `name` and `message` of a rule object
- `EnvErrorValueSchema` — validates a single per-field error value object
- `ValidationResultSchema` — validates the full result produced by `validate()`
- `EnvResultSchema` — generic record schema for the `createEnv` output
- `CreateEnvOptionsSchema` — validates the options shape passed to `createEnv`
- `DotenvLoaderOptionsSchema` / `ObjectLoaderOptionsSchema` — validate loader-specific options
## Usage: Basic Example
```ts
import { FieldConfigSchema, ValidationResultSchema } from '@suaveplan/env/schemas';
// Validate an incoming config object from an external source
const raw = JSON.parse(externalJson);
const result = FieldConfigSchema.safeParse(raw);
if (result.success) {
console.log('Valid field config:', result.data);
} else {
console.error('Invalid:', result.error.issues);
}
```
## Usage: Advanced Example
```ts
import { CreateEnvOptionsSchema, ValidationResultSchema } from '@suaveplan/env/schemas';
// Validate a serialised env options payload received over HTTP
async function handleEnvConfig(body: unknown) {
const parsed = CreateEnvOptionsSchema.safeParse(body);
if (!parsed.success) {
return { status: 400, error: parsed.error.flatten() };
}
// Validate a stored validation result
const storedResult = await db.get('last-env-validation');
const resultParsed = ValidationResultSchema.safeParse(storedResult);
return { status: 200, valid: resultParsed.success };
}
```
## API Reference
### `ValidationRuleSchema`
`z.object({ name: z.string(), message: z.string() })`
### `FieldConfigSchema`
Validates `{ isRequired, hasDefault, defaultValue?, isSensitive, description?, rules }`.
### `EnvErrorValueSchema`
Validates `{ key: string, message: string, value?: string }`.
### `ValidationResultSchema`
Validates `{ success: boolean, data: Record<string, unknown>, errors: EnvErrorValueSchema[] }`.
### `EnvResultSchema`
`z.record(z.string(), z.unknown())` — accepts any string-keyed record.
### `CreateEnvOptionsSchema`
Validates the optional fields of `CreateEnvOptions`: `source?`, `prefix?`, `onError?` (enum of `"throw" | "collect" | "warn"`). Does not validate `schema` or `loaders` (not serialisable to Zod schemas).
### `DotenvLoaderOptionsSchema`
Validates `{ content: string, sourceName?: string }`.
### `ObjectLoaderOptionsSchema`
Validates `{ source: Record<string, string?>, sourceName?: string }`.
## Implementation Notes
All schemas use Zod v4's two-argument `z.record(z.string(), z.unknown())` form — Zod v4 requires the key schema to be explicit. Using the single-argument form (`z.record(z.unknown())`) that worked in Zod v3 throws a runtime error in v4.
`CreateEnvOptionsSchema` deliberately omits the `schema` property because Zod schemas for Zod schemas would be circular. The partial schema is still useful for validating the serialisable subset of options (source object, prefix, error mode) when options are transmitted as JSON.
`defaultValue` in `FieldConfigSchema` uses `z.unknown().optional()` because field defaults can be any coerced value type — string, number, boolean, array, or object. Callers that need stricter validation of `defaultValue` should derive a more specific schema using `.extend()` or `.superRefine()`.

View File

@@ -0,0 +1,80 @@
# coerce
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides standalone coercion functions for the five primitive environment variable types: string, number, integer, boolean, and JSON. These functions are extracted from the field builder classes so they can be used independently in custom loaders, middleware, and testing utilities without instantiating a full field builder. Each function is a simple, pure coerce-or-throw operation with no side effects.
## Features
- `coerceString` — trims whitespace, returns the result (never throws)
- `coerceNumber` — rejects empty strings and non-numeric input with `CoercionError`
- `coerceInteger` — rejects non-integers with `CoercionError`
- `coerceBoolean` — maps standard truthy/falsy tokens using `TRUTHY_VALUES` / `FALSY_VALUES` constants
- `coerceJson` — parses with `JSON.parse`, wraps syntax errors in `CoercionError`
- `coerceArray<T>(itemCoerce, separator?)` — factory that returns a splitting coercion function
## Usage: Basic Example
```ts
import {
coerceString,
coerceNumber,
coerceBoolean,
} from '@suaveplan/env/transforms/coerce/coerce';
console.log(coerceString(' hello ')); // "hello"
console.log(coerceNumber('3.14')); // 3.14
console.log(coerceBoolean('yes')); // true
console.log(coerceBoolean('0')); // false
```
## Usage: Advanced Example
```ts
import { coerceArray, coerceNumber, coerceJson } from '@suaveplan/env/transforms/coerce/coerce';
// Split comma-separated integers
const parseInts = coerceArray(coerceNumber, ',');
console.log(parseInts('1,2,3')); // [1, 2, 3]
// Split semicolon-separated JSON blobs
const parseJsonList = coerceArray(coerceJson, ';');
console.log(parseJsonList('{"a":1};{"b":2}')); // [{ a: 1 }, { b: 2 }]
```
## API Reference
### `coerceString: CoerceFn<string>`
`(raw: string) => string.trim()`. Never throws.
### `coerceNumber: CoerceFn<number>`
`(raw: string) => number`. Throws `CoercionError` if the trimmed string is empty or `Number.isNaN(Number(trimmed))`.
### `coerceInteger: CoerceFn<number>`
`(raw: string) => number`. Throws `CoercionError` if the value is NaN or not an integer (`Number.isInteger` check).
### `coerceBoolean: CoerceFn<boolean>`
`(raw: string) => boolean`. Lowercases and trims the input. Checks `TRUTHY_VALUES.includes()` then `FALSY_VALUES.includes()`. Throws `CoercionError` with `targetType = "boolean"` on mismatch.
### `coerceJson: CoerceFn<unknown>`
`(raw: string) => unknown`. Wraps `JSON.parse`; any thrown `SyntaxError` becomes a `CoercionError` with `targetType = "JSON"`.
### `coerceArray<T>(itemCoerce: CoerceFn<T>, separator?: string): CoerceFn<T[]>`
Factory. `separator` defaults to `","`. The returned function splits on the separator, trims each part, filters empty strings, and maps through `itemCoerce`. Returns `[]` for blank input.
## Implementation Notes
`coerceBoolean` uses `TRUTHY_VALUES.includes(lower)` and `FALSY_VALUES.includes(lower)` on the imported constant arrays (both `readonly string[]`) rather than a `Set`. This differs from `BooleanField.coerce`, which stores its values in `Set` instances to allow overrides via `truthy()`/`falsy()`. The standalone function always uses the canonical constant arrays, making it suitable for contexts where custom token overrides are not needed.
`coerceArray` returns `[]` for blank input to match the `ArrayField` behaviour — a blank value means zero items, not a parse error. Callers that need to enforce non-empty arrays should add a post-coercion check or use `ArrayField.minItems(1)`.
The `CoerceFn<T>` type alias is defined in `coerce.types.ts` as `(raw: string) => T`.

View File

@@ -0,0 +1,112 @@
# common (types)
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the shared TypeScript interfaces and utility types that form the structural contract of `@suaveplan/env`. Every other module in the package either imports from this file or is expressed in terms of the types it declares. Keeping all shared types in a single file prevents circular import issues and provides a single source of truth for the public type surface.
## Features
- `ValidationRule<T>` — the rule interface used by all field builders; uses method syntax for bivariant `validate`
- `FieldConfig<T>` — readonly snapshot of a field builder's state at a point in time
- `LoaderResult` / `Loader` — the loader protocol: a named object with a `load()` method returning string key-value pairs
- `ErrorMode` — the three-way union controlling how `createEnv` surfaces failures
- `EnvError` — plain value-object interface for a per-field error entry (not a class)
- `AnyField<T, R>` — the structural interface that all field builder classes satisfy; enables heterogeneous schema records
- `EnvSchema` — `Record<string, AnyField>` alias for schema type parameters
- `InferFieldType<F>`, `RequiredKeys<S>`, `OptionalKeys<S>` — building blocks for the output inference
- `InferEnv<S>` — the main mapped type that converts a schema into its fully-typed output record
- `CreateEnvOptions<S>` — options bag for `createEnv`
- `Simplify<T>` — flattens intersection types into readable single-object shapes
## Usage: Basic Example
```ts
import type {
AnyField,
EnvSchema,
InferEnv,
Loader,
LoaderResult,
} from '@suaveplan/env';
// Describe a custom loader
const myLoader: Loader = {
name: 'custom',
load(): LoaderResult {
return { values: { HOST: 'example.com' }, source: 'custom' };
},
};
// Use InferEnv to type a helper function
function readEnv<S extends EnvSchema>(schema: S, values: Record<string, string>): InferEnv<S> {
// ... implementation
}
```
## Usage: Advanced Example
```ts
import type { AnyField, InferFieldType, RequiredKeys, OptionalKeys, InferEnv } from '@suaveplan/env';
import { str, num } from '@suaveplan/env';
const schema = {
HOST: str().default('localhost'), // AnyField<string, true>
PORT: num().default(3000), // AnyField<number, true>
EXTRA: str().optional(), // AnyField<string, false>
};
// TypeScript computes:
// RequiredKeys<typeof schema> = 'HOST' | 'PORT'
// OptionalKeys<typeof schema> = 'EXTRA'
// InferEnv<typeof schema> = { HOST: string; PORT: number; EXTRA?: string }
type Env = InferEnv<typeof schema>;
```
## API Reference
### `ValidationRule<T>`
| Property | Type | Description |
|---|---|---|
| `name` | `string` | Machine-readable rule identifier |
| `message` | `string` | Error message shown when the rule fails |
| `validate(value: T)` | `boolean` | Returns `true` if the value passes |
### `FieldConfig<T>`
Readonly snapshot of a field builder's state: `isRequired`, `hasDefault`, `defaultValue?`, `isSensitive`, `description?`, `rules`.
### `Loader` / `LoaderResult`
`Loader`: `{ name: string; load(): LoaderResult }`. `LoaderResult`: `{ values: Record<string, string>; source: string }`.
### `ErrorMode`
`"throw" | "collect" | "warn"` — controls `createEnv` error handling.
### `EnvError`
`{ key: string; message: string; value?: string }` — plain value object for per-field error accumulation.
### `AnyField<T, R>`
Structural interface every field class satisfies. Contains `_isRequired`, `_hasDefault`, `_defaultValue?`, `_isSensitive`, `_description?`, `_rules`, and `coerce(raw: string): T`.
### `InferEnv<S>`
Mapped type: required keys (`AnyField<T, true>`) produce non-optional output properties; optional keys (`AnyField<T, false>`) produce `?: T` properties. Flattened via `Simplify<T>`.
### `CreateEnvOptions<S>`
Options for `createEnv`: `schema`, `source?`, `loaders?`, `prefix?`, `onError?`.
## Implementation Notes
`ValidationRule<T>` uses method syntax (`validate(value: T): boolean`) rather than a property function type to enable bivariance on `T`. This allows `StringField` (rules over `string`) to be assignable to `AnyField<unknown>` without an explicit variance annotation.
`Simplify<T>` is defined as `{ [K in keyof T]: T[K] } & object`. The `& object` intersection prevents the type from expanding to `string | number` when `T` is a primitive-union intersection, ensuring IDE tooltips display the concrete shape.
`RequiredKeys` and `OptionalKeys` use `S[K] extends AnyField<unknown, true>` conditional checks. Because `AnyField` uses method syntax for `validate`, this extends-check works correctly across all concrete field classes despite different `T` parameters.

View File

@@ -0,0 +1,80 @@
# interpolation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements `${VAR}` and `$VAR` token interpolation for use inside the dotenv parser. When a dotenv file references an earlier variable in a value string, the `interpolate` function substitutes the variable's resolved value. The three exported functions — `interpolate`, `hasInterpolation`, `extractVariables` — can also be used independently in custom loaders or configuration preprocessors that need to resolve variable references in arbitrary strings.
## Features
- `interpolate(value, lookup, defaultValue?)` — replaces all `${VAR}` and `$VAR` tokens using a lookup map; uses `defaultValue` (default `""`) for absent variables
- `hasInterpolation(value)` — fast predicate returning `true` if the string contains any interpolation token
- `extractVariables(value)` — returns a de-duplicated array of all variable names referenced in the string
- Uses a single shared `INTERPOLATION_PATTERN` constant from `constants/index.ts` for consistent regex behaviour
- Pure functions — no IO, no side effects, fully deterministic
## Usage: Basic Example
```ts
import { interpolate, hasInterpolation } from '@suaveplan/env/utils/interpolation';
const lookup = { HOST: 'example.com', PORT: '3000' };
console.log(interpolate('${HOST}:${PORT}/api', lookup)); // "example.com:3000/api"
console.log(interpolate('$HOST:$PORT/api', lookup)); // "example.com:3000/api"
console.log(interpolate('static-value', lookup)); // "static-value"
console.log(hasInterpolation('hello ${WORLD}')); // true
console.log(hasInterpolation('no variables here')); // false
```
## Usage: Advanced Example
```ts
import { interpolate, extractVariables } from '@suaveplan/env/utils/interpolation';
const content = 'postgres://${DB_USER}:${DB_PASS}@${DB_HOST}/${DB_NAME}';
// Check which variables are needed before resolving
const needed = extractVariables(content);
console.log(needed); // ["DB_USER", "DB_PASS", "DB_HOST", "DB_NAME"]
// Resolve only if all needed variables are available
const lookup = { DB_USER: 'admin', DB_PASS: 'secret', DB_HOST: 'localhost', DB_NAME: 'app' };
const allPresent = needed.every((v) => v in lookup);
if (allPresent) {
console.log(interpolate(content, lookup));
// "postgres://admin:secret@localhost/app"
}
```
## API Reference
### `interpolate(value, lookup, defaultValue?): string`
| Parameter | Type | Default | Description |
|---|---|---|---|
| `value` | `string` | required | Input string potentially containing `${VAR}` or `$VAR` tokens |
| `lookup` | `Record<string, string \| undefined>` | required | Map of variable names to their resolved values |
| `defaultValue` | `string` | `""` | Substituted when a referenced variable is absent from `lookup` |
Returns the string with all tokens replaced. Non-token substrings are passed through unchanged.
### `hasInterpolation(value: string): boolean`
Returns `true` if `value` contains at least one `${VAR}` or `$VAR` token. Uses `RegExp.test()` for efficiency.
### `extractVariables(value: string): string[]`
Returns an array of unique variable names referenced in `value`, in order of first appearance. Duplicate references to the same variable appear only once.
## Implementation Notes
`INTERPOLATION_PATTERN` is a single regex defined in `constants/index.ts`. All three functions construct a fresh `/global` copy of the pattern via `new RegExp(INTERPOLATION_PATTERN.source, "g")` on each call. This avoids the `lastIndex` state-sharing problem that arises when a stateful global regex is used across multiple calls — reusing the same instance would cause every other call to start matching from a non-zero offset.
The regex has two capture groups: group 1 matches the variable name inside `${...}` and group 2 matches a bare `$VARNAME`. Exactly one of these groups is always non-undefined for any match, so the `(braced ?? bare) as string` cast is safe and avoids dead code from a conditional branch that would never be reached.
`extractVariables` uses a `Set<string>` internally to de-duplicate variable names before converting to an array, preserving insertion order for consistent output.