Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

242
packages/foundation/core/env/README.md vendored Normal file
View File

@@ -0,0 +1,242 @@
# @suaveplan/env - Type-safe environment variable loading: schema-first validation, field types, dotenv loader, presets, structured errors.
## Summary
Type-safe, schema-first environment variable loading for Node.js and edge runtimes. Define a schema of typed field builders, point it at your sources, and receive a frozen record with TypeScript types inferred at compile time — no manual type annotations required.
<!-- begin:auto-docs -->
## Documentation tree
- [create-env](./src/core/create-env/create-env.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [create-env](./src/core/create-env/create-env.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [field](./src/core/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [field](./src/core/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [env-error](./src/errors/env-error.md) — Provides the complete error class hierarchy and a value-object factory for `@suaveplan/env`. All classes extend `Suavep…
- [error-codes](./src/errors/error-codes.md) — Defines and registers all stable error codes for the `@suaveplan/env` package using the `defineCodes` factory from `@su…
- [array-field](./src/fields/array-field/array-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [array-field](./src/fields/array-field/array-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [boolean-field](./src/fields/boolean-field/boolean-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [boolean-field](./src/fields/boolean-field/boolean-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [email-field](./src/fields/email-field/email-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [email-field](./src/fields/email-field/email-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [enum-field](./src/fields/enum-field/enum-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [enum-field](./src/fields/enum-field/enum-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [json-field](./src/fields/json-field/json-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [json-field](./src/fields/json-field/json-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [number-field](./src/fields/number-field/number-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [number-field](./src/fields/number-field/number-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [port-field](./src/fields/port-field/port-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [port-field](./src/fields/port-field/port-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [string-field](./src/fields/string-field/string-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [string-field](./src/fields/string-field/string-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [url-field](./src/fields/url-field/url-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [url-field](./src/fields/url-field/url-field.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [dotenv](./src/loaders/dotenv/dotenv.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [dotenv](./src/loaders/dotenv/dotenv.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [object](./src/loaders/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [object](./src/loaders/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [common](./src/presets/common/common.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [common](./src/presets/common/common.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [server](./src/presets/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [server](./src/presets/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/env.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [env.schemas](./src/schemas/env.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [coerce](./src/transforms/coerce/coerce.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [coerce](./src/transforms/coerce/coerce.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [common](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [utils](./src/utils/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [interpolation](./src/utils/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [create-env](./src/core/create-env/create-env.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/core/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/core/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [env-error](./src/errors/env-error.md) — Provides the complete error class hierarchy and a value-object factory for `@suaveplan/env`. All classes extend `Suavep…
- [error-codes](./src/errors/error-codes.md) — Defines and registers all stable error codes for the `@suaveplan/env` package using the `defineCodes` factory from `@su…
- [array-field](./src/fields/array-field/array-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [array-field](./src/fields/array-field/array-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [boolean-field](./src/fields/boolean-field/boolean-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [boolean-field](./src/fields/boolean-field/boolean-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [email-field](./src/fields/email-field/email-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [email-field](./src/fields/email-field/email-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [enum-field](./src/fields/enum-field/enum-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [enum-field](./src/fields/enum-field/enum-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [json-field](./src/fields/json-field/json-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [json-field](./src/fields/json-field/json-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [number-field](./src/fields/number-field/number-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [number-field](./src/fields/number-field/number-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [port-field](./src/fields/port-field/port-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [port-field](./src/fields/port-field/port-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [string-field](./src/fields/string-field/string-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [string-field](./src/fields/string-field/string-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [url-field](./src/fields/url-field/url-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [url-field](./src/fields/url-field/url-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [dotenv](./src/loaders/dotenv/dotenv.md) — <!-- begin:auto-docs --> _(no submodules)_- [dotenv](./src/loaders/dotenv/dotenv.md) — <!-- begin:auto-docs --> _(no submodules)_- [object](./src/loaders/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_- [object](./src/loaders/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/presets/common/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/presets/common/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/presets/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/presets/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/env.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [env.schemas](./src/schemas/env.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [coerce](./src/transforms/coerce/coerce.md) — <!-- begin:auto-docs --> _(no submodules)_- [coerce](./src/transforms/coerce/coerce.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [interpolation](./src/utils/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [create-env](./src/core/create-env/create-env.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/core/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/core/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [env-error](./src/errors/env-error.md) — Provides the complete error class hierarchy and a value-object factory for `@suaveplan/env`. All classes extend `Suavep…
- [error-codes](./src/errors/error-codes.md) — Defines and registers all stable error codes for the `@suaveplan/env` package using the `defineCodes` factory from `@su…
- [array-field](./src/fields/array-field/array-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [array-field](./src/fields/array-field/array-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [boolean-field](./src/fields/boolean-field/boolean-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [boolean-field](./src/fields/boolean-field/boolean-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [email-field](./src/fields/email-field/email-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [email-field](./src/fields/email-field/email-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [enum-field](./src/fields/enum-field/enum-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [enum-field](./src/fields/enum-field/enum-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [json-field](./src/fields/json-field/json-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [json-field](./src/fields/json-field/json-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [number-field](./src/fields/number-field/number-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [number-field](./src/fields/number-field/number-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [port-field](./src/fields/port-field/port-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [port-field](./src/fields/port-field/port-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [string-field](./src/fields/string-field/string-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [string-field](./src/fields/string-field/string-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [url-field](./src/fields/url-field/url-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [url-field](./src/fields/url-field/url-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [dotenv](./src/loaders/dotenv/dotenv.md) — <!-- begin:auto-docs --> _(no submodules)_- [dotenv](./src/loaders/dotenv/dotenv.md) — <!-- begin:auto-docs --> _(no submodules)_- [object](./src/loaders/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_- [object](./src/loaders/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/presets/common/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/presets/common/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/presets/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/presets/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/env.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [env.schemas](./src/schemas/env.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [coerce](./src/transforms/coerce/coerce.md) — <!-- begin:auto-docs --> _(no submodules)_- [coerce](./src/transforms/coerce/coerce.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [interpolation](./src/utils/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [create-env](./src/core/create-env/create-env.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/core/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/core/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [env-error](./src/errors/env-error.md) — Provides the complete error class hierarchy and a value-object factory for `@suaveplan/env`. All classes extend `Suavep…
- [error-codes](./src/errors/error-codes.md) — Defines and registers all stable error codes for the `@suaveplan/env` package using the `defineCodes` factory from `@su…
- [array-field](./src/fields/array-field/array-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [array-field](./src/fields/array-field/array-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [boolean-field](./src/fields/boolean-field/boolean-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [boolean-field](./src/fields/boolean-field/boolean-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [email-field](./src/fields/email-field/email-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [email-field](./src/fields/email-field/email-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [enum-field](./src/fields/enum-field/enum-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [enum-field](./src/fields/enum-field/enum-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [json-field](./src/fields/json-field/json-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [json-field](./src/fields/json-field/json-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [number-field](./src/fields/number-field/number-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [number-field](./src/fields/number-field/number-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [port-field](./src/fields/port-field/port-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [port-field](./src/fields/port-field/port-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [string-field](./src/fields/string-field/string-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [string-field](./src/fields/string-field/string-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [url-field](./src/fields/url-field/url-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [url-field](./src/fields/url-field/url-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [dotenv](./src/loaders/dotenv/dotenv.md) — <!-- begin:auto-docs --> _(no submodules)_- [dotenv](./src/loaders/dotenv/dotenv.md) — <!-- begin:auto-docs --> _(no submodules)_- [object](./src/loaders/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_- [object](./src/loaders/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/presets/common/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/presets/common/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/presets/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/presets/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/env.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [env.schemas](./src/schemas/env.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [coerce](./src/transforms/coerce/coerce.md) — <!-- begin:auto-docs --> _(no submodules)_- [coerce](./src/transforms/coerce/coerce.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [interpolation](./src/utils/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_- [create-env](./src/core/create-env/create-env.md) — <!-- begin:auto-docs --> _(no submodules)_- [field](./src/core/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [field](./src/core/field/field.md) — <!-- begin:auto-docs --> _(no submodules)_- [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_ - [validator](./src/core/validator/validator.md) — <!-- begin:auto-docs --> _(no submodules)_- [env-error](./src/errors/env-error.md) — Provides the complete error class hierarchy and a value-object factory for `@suaveplan/env`. All classes extend `Suavep…
- [error-codes](./src/errors/error-codes.md) — Defines and registers all stable error codes for the `@suaveplan/env` package using the `defineCodes` factory from `@su…
- [array-field](./src/fields/array-field/array-field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [array-field](./src/fields/array-field/array-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [boolean-field](./src/fields/boolean-field/boolean-field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [boolean-field](./src/fields/boolean-field/boolean-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [email-field](./src/fields/email-field/email-field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [email-field](./src/fields/email-field/email-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [enum-field](./src/fields/enum-field/enum-field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [enum-field](./src/fields/enum-field/enum-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [json-field](./src/fields/json-field/json-field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [json-field](./src/fields/json-field/json-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [number-field](./src/fields/number-field/number-field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [number-field](./src/fields/number-field/number-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [port-field](./src/fields/port-field/port-field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [port-field](./src/fields/port-field/port-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [string-field](./src/fields/string-field/string-field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [string-field](./src/fields/string-field/string-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [url-field](./src/fields/url-field/url-field.md) — <!-- begin:auto-docs --> _(no submodules)_ - [url-field](./src/fields/url-field/url-field.md) — <!-- begin:auto-docs --> _(no submodules)_- [dotenv](./src/loaders/dotenv/dotenv.md) — <!-- begin:auto-docs --> _(no submodules)_ - [dotenv](./src/loaders/dotenv/dotenv.md) — <!-- begin:auto-docs --> _(no submodules)_- [object](./src/loaders/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_ - [object](./src/loaders/object/object.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/presets/common/common.md) — <!-- begin:auto-docs --> _(no submodules)_ - [common](./src/presets/common/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/presets/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_ - [server](./src/presets/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/env.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ - [env.schemas](./src/schemas/env.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [coerce](./src/transforms/coerce/coerce.md) — <!-- begin:auto-docs --> _(no submodules)_ - [coerce](./src/transforms/coerce/coerce.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_ - [common](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_ - [interpolation](./src/utils/interpolation.md) — <!-- begin:auto-docs --> _(no submodules)_
## Features
- **Nine field builders**: `str`, `num`, `bool`, `enums`, `url`, `email`, `port`, `json`, `array`
- **Fluent builder chains**: constraints, defaults, sensitive marking, and descriptions per field
- **Pluggable loaders**: dotenv file, object (`process.env`), or custom loaders with priority ordering
- **Three error modes**: `throw` (default), `collect` (partial data), `warn` (log and continue)
- **Full-error accumulation**: reports every broken field in a single run, not just the first
- **Sensitive value redaction**: `maskSensitive` replaces secret values with `****` before logging
- **Preset libraries**: `commonFields` and `serverFields` for the most frequent env variables
- **Zod runtime schemas**: for validation at serialisation boundaries
- **100% Istanbul coverage**: foundation-grade reliability
## Installation
```bash
bun add @suaveplan/env
```
## Quick Start
```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),
API_KEY: str().nonempty().sensitive(),
},
source: process.env,
});
// Full TypeScript types — no manual annotation needed
console.log(env.HOST); // string
console.log(env.PORT); // number
console.log(env.DEBUG); // boolean
console.log(env.API_KEY); // string
```
## Field Builders
| Factory | Output type | Key methods |
|---|---|---|
| `str()` | `string` | `trim`, `lowercase`, `uppercase`, `minLength`, `maxLength`, `pattern`, `nonempty`, `startsWith`, `endsWith`, `includes` |
| `num()` | `number` | `min`, `max`, `integer`, `positive`, `nonNegative` |
| `bool()` | `boolean` | `truthy(values)`, `falsy(values)` |
| `enums(values)` | `"a" \| "b" \| …` | `caseInsensitive`, `getAllowedValues` |
| `url()` | `string` | `protocols(list)`, `noTrailingSlash` |
| `email()` | `string` | `normalise`, `pattern(regex)` |
| `port()` | `number` | `min(n)`, `max(n)` |
| `json<T>()` | `T` (default `unknown`) | `validator(fn)` |
| `array(itemField)` | `T[]` | `separator`, `noTrim`, `keepEmpty`, `minItems`, `maxItems` |
All builders share: `optional()`, `default(value)`, `sensitive()`, `describe(text)`, `addRule(options)`.
## Loaders
```ts
import { createEnv, str } from '@suaveplan/env';
import { dotenvLoader, objectLoader } from '@suaveplan/env/loaders';
const env = createEnv({
schema: { DB_URL: str().sensitive() },
loaders: [
dotenvLoader({ path: '.env' }), // from file
objectLoader({ source: process.env }), // from process.env (wins)
],
});
```
Later loaders override earlier ones for the same key. `source` (if provided) overrides all loaders.
## Error Modes
```ts
// Default: throws EnvValidationError with all failures listed
const env = createEnv({ schema, source: process.env });
// Collect: returns partial data silently
const env = createEnv({ schema, source: process.env, onError: 'collect' });
// Warn: logs each failure via console.warn, returns partial data
const env = createEnv({ schema, source: process.env, onError: 'warn' });
```
## Prefix Support
```ts
// Reads APP_HOST, APP_PORT from process.env; output keys are HOST, PORT
const env = createEnv({
schema: { HOST: str().default('localhost'), PORT: num().default(3000) },
source: process.env,
prefix: 'APP_',
});
```
## Presets
```ts
import { commonFields } from '@suaveplan/env/presets/common/common';
import { serverFields, databaseUrlField, sessionSecretField } from '@suaveplan/env/presets/server/server';
import { createEnv, str } from '@suaveplan/env';
const env = createEnv({
schema: {
...commonFields, // NODE_ENV, PORT, HOST, DEBUG, LOG_LEVEL
DATABASE_URL: databaseUrlField(),
SESSION_SECRET: sessionSecretField(),
CUSTOM: str().optional(),
},
source: process.env,
});
```
## Sensitive Values
```ts
import { validate, maskSensitive } from '@suaveplan/env/core/validator/validator';
import { str } from '@suaveplan/env';
const schema = { SECRET: str().sensitive(), PUBLIC: str() };
const result = validate({ schema, values: { SECRET: 'my-secret', PUBLIC: 'ok' } });
const safe = maskSensitive(schema, result.data);
console.log(safe.SECRET); // "****"
console.log(safe.PUBLIC); // "ok"
```
## Errors
```ts
import { EnvValidationError, CoercionError } from '@suaveplan/env/errors';
import { ENV_CODES } from '@suaveplan/env/errors';
try {
createEnv({ schema, source: {} });
} catch (err) {
if (err instanceof EnvValidationError) {
console.log(err.code); // "ENV.VALIDATION_FAILED"
console.log(err.errors); // [{ key, message }]
}
}
```
## Zod Schemas
Runtime schemas for validation at API or persistence boundaries:
```ts
import { FieldConfigSchema, ValidationResultSchema } from '@suaveplan/env/schemas';
const result = FieldConfigSchema.safeParse(unknownPayload);
```
## Module Reference
| Co-located module | Description |
|---|---|
| [`src/core/create-env/create-env.md`](src/core/create-env/create-env.md) | `createEnv` orchestrator |
| [`src/core/field/field.md`](src/core/field/field.md) | `BaseField` abstract base |
| [`src/core/validator/validator.md`](src/core/validator/validator.md) | `validate` and `maskSensitive` |
| [`src/errors/error-codes.md`](src/errors/error-codes.md) | `ENV_CODES` registry |
| [`src/errors/env-error.md`](src/errors/env-error.md) | Error class hierarchy |
| [`src/fields/string-field/string-field.md`](src/fields/string-field/string-field.md) | `str()` |
| [`src/fields/number-field/number-field.md`](src/fields/number-field/number-field.md) | `num()` |
| [`src/fields/boolean-field/boolean-field.md`](src/fields/boolean-field/boolean-field.md) | `bool()` |
| [`src/fields/enum-field/enum-field.md`](src/fields/enum-field/enum-field.md) | `enums()` |
| [`src/fields/url-field/url-field.md`](src/fields/url-field/url-field.md) | `url()` |
| [`src/fields/email-field/email-field.md`](src/fields/email-field/email-field.md) | `email()` |
| [`src/fields/port-field/port-field.md`](src/fields/port-field/port-field.md) | `port()` |
| [`src/fields/json-field/json-field.md`](src/fields/json-field/json-field.md) | `json()` |
| [`src/fields/array-field/array-field.md`](src/fields/array-field/array-field.md) | `array()` |
| [`src/loaders/dotenv/dotenv.md`](src/loaders/dotenv/dotenv.md) | `parseDotenv`, `dotenvLoader` |
| [`src/loaders/object/object.md`](src/loaders/object/object.md) | `objectLoader` |
| [`src/transforms/coerce/coerce.md`](src/transforms/coerce/coerce.md) | Standalone coercion functions |
| [`src/presets/common/common.md`](src/presets/common/common.md) | Common server field presets |
| [`src/presets/server/server.md`](src/presets/server/server.md) | Server connection field presets |
| [`src/schemas/env.schemas.md`](src/schemas/env.schemas.md) | Zod runtime schemas |
| [`src/types/common.md`](src/types/common.md) | Shared TypeScript types |
| [`src/utils/interpolation.md`](src/utils/interpolation.md) | `${VAR}` interpolation utilities |

View File

@@ -0,0 +1,105 @@
{
"name": "@suaveplan/env",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Type-safe environment variable loading: schema-first validation, field types, dotenv loader, presets, structured errors.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./core": {
"import": {
"types": "./dist/core/create-env/create-env.d.ts",
"default": "./dist/core/create-env/create-env.js"
},
"require": {
"types": "./dist/core/create-env/create-env.d.ts",
"default": "./dist/core/create-env/create-env.js"
}
},
"./fields": {
"import": {
"types": "./dist/fields/index.d.ts",
"default": "./dist/fields/index.js"
},
"require": {
"types": "./dist/fields/index.d.ts",
"default": "./dist/fields/index.js"
}
},
"./loaders": {
"import": {
"types": "./dist/loaders/index.d.ts",
"default": "./dist/loaders/index.js"
},
"require": {
"types": "./dist/loaders/index.d.ts",
"default": "./dist/loaders/index.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/env-error.d.ts",
"default": "./dist/errors/env-error.js"
},
"require": {
"types": "./dist/errors/env-error.d.ts",
"default": "./dist/errors/env-error.js"
}
},
"./schemas": {
"import": {
"types": "./dist/schemas/env.schemas.d.ts",
"default": "./dist/schemas/env.schemas.js"
},
"require": {
"types": "./dist/schemas/env.schemas.d.ts",
"default": "./dist/schemas/env.schemas.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"bench": "bun run src/__benchmarks__/env.bench.ts",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
}
}

View File

@@ -0,0 +1,37 @@
/** Default separator character for array fields. */
export const DEFAULT_ARRAY_SEPARATOR = ",";
/** Values coerced to boolean `true` by BooleanField. */
export const TRUTHY_VALUES: readonly string[] = [
"true",
"1",
"yes",
"on",
"enabled",
];
/** Values coerced to boolean `false` by BooleanField. */
export const FALSY_VALUES: readonly string[] = [
"false",
"0",
"no",
"off",
"disabled",
"",
];
/** Minimum valid TCP port number. */
export const PORT_MIN = 1;
/** Maximum valid TCP port number. */
export const PORT_MAX = 65_535;
/** Basic email validation regex covering the vast majority of real addresses. */
export const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/**
* Interpolation variable pattern matching `${VAR}` and `$VAR` forms.
* The global flag is required; callers must reset `lastIndex` before use.
*/
export const INTERPOLATION_PATTERN =
/\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g;

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,381 @@
/**
* Spec-conformance tests for `createEnv`. Covers scenarios from
* `openspec/specs/env/spec.md` that the original suite did not pin
* explicitly: schema-driven literal type preservation, optional vs
* required key narrowing, dotenv→source loader chain with
* interpolation, maskSensitive on the wire, and ENV.* error codes
* for invalid env vars.
*/
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import { EnvValidationError } from "../../errors/env-error.js";
import { ENV_CODES } from "../../errors/error-codes.js";
import { bool } from "../../fields/boolean-field/boolean-field.js";
import { enums } from "../../fields/enum-field/enum-field.js";
import { num } from "../../fields/number-field/number-field.js";
import { port } from "../../fields/port-field/port-field.js";
import { str } from "../../fields/string-field/string-field.js";
import { dotenvLoader, parseDotenv } from "../../loaders/dotenv/dotenv.js";
import { objectLoader } from "../../loaders/object/object.js";
import type { Loader } from "../../types/common.js";
import { maskSensitive } from "../validator/validator.js";
import { createEnv } from "./create-env.js";
describe("createEnv — schema-typed result preserves literal types", () => {
it("narrows required vs optional keys, enum literals, and field types", {
timeout: 5000,
}, () => {
const env = createEnv({
schema: {
NODE_ENV: enums(["dev", "prod"] as const),
PORT: port().default(3000),
HOST: str().optional(),
DEBUG: bool().default(false),
WORKERS: num().default(4),
},
source: { NODE_ENV: "prod" },
});
// Required (with defaults or values) → never undefined.
expectTypeOf(env.NODE_ENV).toEqualTypeOf<"dev" | "prod">();
expectTypeOf(env.PORT).toEqualTypeOf<number>();
expectTypeOf(env.DEBUG).toEqualTypeOf<boolean>();
expectTypeOf(env.WORKERS).toEqualTypeOf<number>();
// Optional → may be undefined.
expectTypeOf(env.HOST).toEqualTypeOf<string | undefined>();
// Runtime checks to keep istanbul satisfied.
expect(env.NODE_ENV).toBe("prod");
expect(env.PORT).toBe(3000);
expect(env.DEBUG).toBe(false);
expect(env.WORKERS).toBe(4);
expect(env.HOST).toBeUndefined();
});
});
describe("createEnv — loader chain merges in declared order", () => {
it("later loaders win; source overrides loader values", {
timeout: 5000,
}, () => {
const first: Loader = {
name: "first",
load: () => ({
values: { A: "a-from-first", B: "b-from-first" },
source: "first",
}),
};
const second = objectLoader({
source: { B: "b-from-second", C: "c-from-second" },
});
const env = createEnv({
schema: {
A: str(),
B: str(),
C: str(),
D: str().optional(),
},
loaders: [first, second],
source: { D: "d-from-source" },
});
expect(env.A).toBe("a-from-first");
expect(env.B).toBe("b-from-second");
expect(env.C).toBe("c-from-second");
expect(env.D).toBe("d-from-source");
});
});
describe("parseDotenv — interpolation chain is order-sensitive", () => {
it("later keys can reference earlier keys via ${VAR}", {
timeout: 5000,
}, () => {
const out = parseDotenv(
["BASE=hello", 'GREETING="${BASE} world"', ""].join("\n")
);
expect(out.BASE).toBe("hello");
expect(out.GREETING).toBe("hello world");
});
it("is pure: same input yields equal output map", { timeout: 5000 }, () => {
const content = "X=1\nY=${X}-tail\n";
const a = parseDotenv(content);
const b = parseDotenv(content);
expect(a).toEqual(b);
});
});
describe("dotenvLoader — graceful fallback on missing file", () => {
it("returns empty values when file does not exist (no throw)", {
timeout: 5000,
}, () => {
const loader = dotenvLoader({ path: "./.env.does-not-exist-xyz" });
const result = loader.load();
expect(result.values).toEqual({});
});
});
describe("maskSensitive — never leaks sensitive raw values on the wire", () => {
it("replaces sensitive values without mutating the original object", {
timeout: 5000,
}, () => {
const schema = {
PUBLIC: str(),
SECRET: str().sensitive(),
};
const data: Record<string, unknown> = {
PUBLIC: "shown",
SECRET: "super-secret-value",
};
const masked = maskSensitive(schema, data);
// Sensitive value redacted.
expect(masked.SECRET).not.toBe("super-secret-value");
expect(String(masked.SECRET)).toMatch(/\*+/);
// Public value preserved.
expect(masked.PUBLIC).toBe("shown");
// Original not mutated.
expect(data.SECRET).toBe("super-secret-value");
expect(masked).not.toBe(data);
});
});
describe("createEnv — ENV.* error codes for invalid env vars", () => {
it("throws EnvValidationError with code ENV.VALIDATION_FAILED", {
timeout: 5000,
}, () => {
const fn = (): unknown =>
createEnv({
schema: { PORT: port() },
source: { PORT: "not-a-port" },
});
expect(fn).toThrow(EnvValidationError);
try {
fn();
} catch (err) {
expect(err).toBeInstanceOf(EnvValidationError);
expect((err as EnvValidationError).code).toBe(
ENV_CODES.VALIDATION_FAILED
);
expect((err as EnvValidationError).code).toMatch(/^ENV\./);
}
});
it("missing required field surfaces under ENV.VALIDATION_FAILED", {
timeout: 5000,
}, () => {
try {
createEnv({ schema: { REQUIRED: str() }, source: {} });
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(EnvValidationError);
expect((err as EnvValidationError).code).toBe(
ENV_CODES.VALIDATION_FAILED
);
const errs = (err as EnvValidationError).errors;
expect(errs.some((e) => e.key === "REQUIRED")).toBe(true);
}
});
});
describe("ENV_CODES — registered under ENV.* namespace", () => {
it("every code carries the ENV prefix and is literal-typed", {
timeout: 5000,
}, () => {
expectTypeOf(
ENV_CODES.VALIDATION_FAILED
).toEqualTypeOf<"ENV.VALIDATION_FAILED">();
expectTypeOf(
ENV_CODES.MISSING_REQUIRED
).toEqualTypeOf<"ENV.MISSING_REQUIRED">();
expectTypeOf(ENV_CODES.PARSE_ERROR).toEqualTypeOf<"ENV.PARSE_ERROR">();
expectTypeOf(
ENV_CODES.INVALID_FORMAT
).toEqualTypeOf<"ENV.INVALID_FORMAT">();
for (const value of Object.values(ENV_CODES)) {
expect(value).toMatch(/^ENV\./);
}
});
});
describe("createEnv — cross-loader ${VAR} interpolation pass", () => {
it("expands ${VAR} tokens across the merged values map before coercion", {
timeout: 5000,
}, () => {
const env = createEnv({
schema: {
BASE: str(),
URL: str(),
},
// BASE is supplied by the loader; URL references BASE via ${...}
// and is supplied by `source`. The interpolation pass MUST resolve
// `${BASE}` against the merged map before coercion runs.
loaders: [
objectLoader({ source: { BASE: "https://example.com" } }),
],
source: { URL: "${BASE}/api" },
});
expect(env.BASE).toBe("https://example.com");
expect(env.URL).toBe("https://example.com/api");
});
it("leaves unresolved tokens as empty (interpolate default)", {
timeout: 5000,
}, () => {
const env = createEnv({
schema: { OUT: str() },
source: { OUT: "prefix-${MISSING}-suffix" },
});
expect(env.OUT).toBe("prefix--suffix");
});
});
describe("Zod boundary validation surfaces ENV.VALIDATION_FAILED", () => {
it("createEnv rejects an invalid options.onError value", {
timeout: 5000,
}, () => {
try {
createEnv({
schema: { X: str() },
source: { X: "ok" },
// @ts-expect-error — invalid onError exercise the boundary check
onError: "ignore",
});
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(EnvValidationError);
expect((err as EnvValidationError).code).toBe(
ENV_CODES.VALIDATION_FAILED
);
}
});
it("dotenvLoader rejects a non-string path", { timeout: 5000 }, () => {
try {
// @ts-expect-error — invalid path exercises the boundary check
dotenvLoader({ path: 42 });
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(EnvValidationError);
expect((err as EnvValidationError).code).toBe(
ENV_CODES.VALIDATION_FAILED
);
}
});
it("dotenvLoader rejects a non-object options argument with the 'options' fallback key", {
timeout: 5000,
}, () => {
try {
// @ts-expect-error — non-object exercises the empty-path branch
dotenvLoader("oops");
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(EnvValidationError);
const errs = (err as EnvValidationError).errors;
expect(errs.some((e) => e.key === "options")).toBe(true);
}
});
it("objectLoader rejects a missing source", { timeout: 5000 }, () => {
try {
// @ts-expect-error — invalid options exercise the boundary check
objectLoader({});
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(EnvValidationError);
expect((err as EnvValidationError).code).toBe(
ENV_CODES.VALIDATION_FAILED
);
}
});
it("objectLoader rejects a non-object options argument with the 'options' fallback key", {
timeout: 5000,
}, () => {
try {
// @ts-expect-error — non-object exercises the empty-path branch
objectLoader(null);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(EnvValidationError);
const errs = (err as EnvValidationError).errors;
expect(errs.some((e) => e.key === "options")).toBe(true);
}
});
it("createEnv rejects a non-object options argument with the 'options' fallback key", {
timeout: 5000,
}, () => {
try {
// @ts-expect-error — non-object exercises the empty-path branch
createEnv(null);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(EnvValidationError);
const errs = (err as EnvValidationError).errors;
expect(errs.some((e) => e.key === "options")).toBe(true);
}
});
});
describe("createEnv — interpolation pass branch coverage", () => {
it("skips undefined entries when building the lookup map", {
timeout: 5000,
}, () => {
// EXPLICIT_UNDEFINED is supplied as `undefined` directly via source —
// the spread keeps the key present with value `undefined` in the
// merged map. The interpolation lookup MUST skip it; URL still
// resolves against the defined values.
const env = createEnv({
schema: {
BASE: str(),
EXPLICIT_UNDEFINED: str().optional(),
URL: str(),
},
source: {
BASE: "x",
EXPLICIT_UNDEFINED: undefined,
URL: "${BASE}-y",
},
});
expect(env.URL).toBe("x-y");
expect(env.EXPLICIT_UNDEFINED).toBeUndefined();
});
it("treats a self-referencing token as a stable fixed point", {
timeout: 5000,
}, () => {
// When `${X}` resolves to literal `${X}`, the substitution is a
// no-op and the loop terminates without mutating the value — this
// exercises the `next === v` branch of expandInterpolation.
const env = createEnv({
schema: { X: str() },
source: { X: "${X}" },
});
// Unresolved reference falls through `interpolate`'s empty-string
// default; `expandInterpolation` notices no change after one round.
expect(typeof env.X).toBe("string");
});
});
describe("createEnv — OTel instrumentation is non-throwing", () => {
it("records metrics for both success and error outcomes without throwing", {
timeout: 5000,
}, () => {
// No SDK is registered → noop instruments. The contract is that
// instrumentation MUST NOT throw or suppress errors — both branches
// must complete cleanly.
const ok = createEnv({
schema: { A: str().default("a") },
source: {},
});
expect(ok.A).toBe("a");
try {
createEnv({ schema: { REQUIRED: str() }, source: {} });
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(EnvValidationError);
}
});
});

View File

@@ -0,0 +1,133 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import { EnvValidationError } from "../../errors/env-error.js";
import { bool } from "../../fields/boolean-field/boolean-field.js";
import { num } from "../../fields/number-field/number-field.js";
import { port } from "../../fields/port-field/port-field.js";
import { str } from "../../fields/string-field/string-field.js";
import { objectLoader } from "../../loaders/object/object.js";
import { createEnv } from "./create-env.js";
describe("createEnv", () => {
it("resolves values from source", { timeout: 5000 }, () => {
const env = createEnv({
schema: { HOST: str(), PORT: num() },
source: { HOST: "localhost", PORT: "3000" },
});
expect(env.HOST).toBe("localhost");
expect(env.PORT).toBe(3000);
});
it("uses default values when source key absent", { timeout: 5000 }, () => {
const env = createEnv({
schema: { DEBUG: bool().default(false) },
source: {},
});
expect(env.DEBUG).toBe(false);
});
it("throws EnvValidationError in throw mode (default)", {
timeout: 5000,
}, () => {
expect(() =>
createEnv({
schema: { REQUIRED: str() },
source: {},
})
).toThrow(EnvValidationError);
});
it("collect mode returns partial data without throwing", {
timeout: 5000,
}, () => {
let result: Record<string, unknown> | undefined;
expect(() => {
result = createEnv({
schema: { MISSING: str(), PRESENT: str() },
source: { PRESENT: "hello" },
onError: "collect",
});
}).not.toThrow();
expect(result?.PRESENT).toBe("hello");
});
it("warn mode logs and returns partial data", { timeout: 5000 }, () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const result = createEnv({
schema: { MISSING: str() },
source: {},
onError: "warn",
});
expect(warn).toHaveBeenCalled();
expect(result.MISSING).toBeUndefined();
warn.mockRestore();
});
it("resolves values from loaders", { timeout: 5000 }, () => {
const loader = objectLoader({ source: { PORT: "4000" } });
const env = createEnv({
schema: { PORT: num() },
loaders: [loader],
});
expect(env.PORT).toBe(4000);
});
it("source overrides loader values", { timeout: 5000 }, () => {
const loader = objectLoader({ source: { PORT: "4000" } });
const env = createEnv({
schema: { PORT: num() },
loaders: [loader],
source: { PORT: "9000" },
});
expect(env.PORT).toBe(9000);
});
it("later loader overrides earlier loader", { timeout: 5000 }, () => {
const l1 = objectLoader({ source: { PORT: "3000" } });
const l2 = objectLoader({ source: { PORT: "5000" } });
const env = createEnv({
schema: { PORT: num() },
loaders: [l1, l2],
});
expect(env.PORT).toBe(5000);
});
it("applies prefix when looking up values", { timeout: 5000 }, () => {
const env = createEnv({
schema: { PORT: num() },
source: { APP_PORT: "7000" },
prefix: "APP_",
});
expect(env.PORT).toBe(7000);
});
it("returns frozen object", { timeout: 5000 }, () => {
const env = createEnv({
schema: { X: str().default("a") },
source: {},
});
expect(Object.isFrozen(env)).toBe(true);
});
it("validates field rules after coercion", { timeout: 5000 }, () => {
expect(() =>
createEnv({
schema: { PORT: port() },
source: { PORT: "99999" },
})
).toThrow(EnvValidationError);
});
it("error message lists all failing fields", { timeout: 5000 }, () => {
let err: EnvValidationError | undefined;
try {
createEnv({
schema: { A: str(), B: num() },
source: {},
});
} catch (e) {
if (e instanceof EnvValidationError) err = e;
}
expect(err).toBeDefined();
expect(err!.errors.length).toBe(2);
});
});

View File

@@ -0,0 +1,144 @@
import { EnvValidationError } from "../../errors/env-error.js";
import { CreateEnvOptionsSchema } from "../../schemas/env.schemas.js";
import type {
CreateEnvOptions,
EnvError as EnvErrorValue,
EnvSchema,
InferEnv,
Loader,
} from "../../types/common.js";
import { hasInterpolation, interpolate } from "../../utils/interpolation.js";
import { maskSensitive, validate } from "../validator/validator.js";
function zodIssuesToEnvErrors(
issues: ReadonlyArray<{
path: ReadonlyArray<PropertyKey>;
message: string;
}>
): EnvErrorValue[] {
return issues.map((i) => ({
key: i.path.length > 0 ? i.path.map(String).join(".") : "options",
message: i.message,
}));
}
/**
* Merge values from multiple loaders in order.
* Later loaders override earlier ones for the same key.
*/
function mergeLoaders(loaders: ReadonlyArray<Loader>): Record<string, string> {
const merged: Record<string, string> = {};
for (const loader of loaders) {
const result = loader.load();
for (const [key, value] of Object.entries(result.values)) {
merged[key] = value;
}
}
return merged;
}
/**
* Build the merged values map from loaders and an optional direct source.
* The `source` (e.g. `process.env`) takes priority over loader values.
*/
function buildValues(
source: Record<string, string | undefined> | undefined,
loaders: ReadonlyArray<Loader> | undefined
): Record<string, string | undefined> {
const fromLoaders = loaders ? mergeLoaders(loaders) : {};
const fromSource = source ?? {};
return { ...fromLoaders, ...fromSource };
}
/**
* Apply a single interpolation pass over the merged values map. Each
* `${VAR}` / `$VAR` token is resolved against the same map, so a value
* supplied by `source` may reference a key supplied by a loader.
* Unresolved tokens fall back to the empty string (the documented
* default of {@link interpolate}).
*/
function interpolateMap(
merged: Record<string, string | undefined>
): Record<string, string | undefined> {
const lookup: Record<string, string> = {};
for (const [k, v] of Object.entries(merged)) {
if (v !== undefined) lookup[k] = v;
}
const out: Record<string, string | undefined> = {};
for (const [k, v] of Object.entries(merged)) {
out[k] =
v !== undefined && hasInterpolation(v) ? interpolate(v, lookup) : v;
}
return out;
}
/**
* Create a fully-typed, validated environment record.
*
* Reads values from the provided `source` and/or `loaders`, validates
* each field against the schema, and returns a frozen record with types
* inferred from the schema definition. Sensitive values are masked with
* `****` in the returned record when logged externally.
*
* Behaviour on validation failure depends on `onError`:
* - `'throw'` (default) — throws `EnvValidationError` with all failures listed.
* - `'collect'` — returns partial data; access `.errors` on the thrown exception.
* - `'warn'` — logs each failure via `console.warn` and returns partial data.
*
* @typeParam S - The env schema type.
* @param options - Schema, source, loaders, prefix, and error mode.
* @returns A frozen, fully-typed environment record.
*
* @throws {EnvValidationError} When validation fails and `onError === 'throw'`.
*
* @example
* ```ts
* const env = createEnv({
* schema: {
* PORT: port().default(3000),
* HOST: str().default('0.0.0.0'),
* DEBUG: bool().default(false),
* },
* source: process.env,
* });
* // env.PORT is number, env.HOST is string, env.DEBUG is boolean
* ```
*/
export function createEnv<S extends EnvSchema>(
options: CreateEnvOptions<S>
): Readonly<InferEnv<S>> {
const optionsParse = CreateEnvOptionsSchema.safeParse(options);
if (!optionsParse.success) {
throw new EnvValidationError(
zodIssuesToEnvErrors(optionsParse.error.issues)
);
}
const { schema, source, loaders, prefix, onError = "throw" } = options;
const merged = buildValues(source, loaders);
const values = interpolateMap(merged);
const validateInput = prefix
? { schema, values, prefix }
: { schema, values };
const result = validate(validateInput);
if (!result.success) {
if (onError === "throw") {
throw new EnvValidationError(result.errors);
}
if (onError === "warn") {
for (const err of result.errors) {
const masked = maskSensitive(schema, { [err.key]: err.value });
const display = masked[err.key] ?? err.value;
console.warn(
`[env] ${err.key}: ${err.message} (got: ${display})`
);
}
}
// 'collect' mode — fall through and return partial data
}
return Object.freeze(result.data) as Readonly<InferEnv<S>>;
}

View File

@@ -0,0 +1,14 @@
import type { EnvError, EnvSchema, InferEnv } from "../../types/common.js";
/**
* A map of field keys to the array of error messages that occurred
* during a `'collect'` mode `createEnv` run.
*/
export type CollectedErrors = ReadonlyMap<string, ReadonlyArray<EnvError>>;
/**
* The typed result produced by `createEnv<S>`.
*
* Keys are inferred from the schema; sensitive values are already masked.
*/
export type EnvResult<S extends EnvSchema> = Readonly<InferEnv<S>>;

View File

@@ -0,0 +1,2 @@
export { createEnv } from "./create-env";
export type { CollectedErrors, EnvResult } from "./create-env.types";

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,89 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { BaseField } from "./field.js";
class ConcreteField extends BaseField<string> {
coerce(raw: string): string {
return raw;
}
markRequired() {
this._markRequired();
return this;
}
markOptional() {
this._markOptional();
return this;
}
setDefault(v: string) {
this._setDefault(v);
return this;
}
}
describe("BaseField", () => {
it("defaults to required", { timeout: 5000 }, () => {
const f = new ConcreteField();
expect(f._isRequired).toBe(true);
expect(f._hasDefault).toBe(false);
expect(f._isSensitive).toBe(false);
});
it("sensitive() marks field as sensitive", { timeout: 5000 }, () => {
const f = new ConcreteField();
f.sensitive();
expect(f._isSensitive).toBe(true);
});
it("describe() sets description", { timeout: 5000 }, () => {
const f = new ConcreteField();
f.describe("my description");
expect(f._description).toBe("my description");
});
it("addRule() pushes a rule", { timeout: 5000 }, () => {
const f = new ConcreteField();
f.addRule({ name: "test", message: "fail", validate: () => false });
expect(f._rules.length).toBe(1);
expect(f._rules[0]!.name).toBe("test");
});
it("getConfig() returns snapshot", { timeout: 5000 }, () => {
const f = new ConcreteField();
f.sensitive().describe("desc");
f.addRule({ name: "r", message: "m", validate: () => true });
const config = f.getConfig();
expect(config.isRequired).toBe(true);
expect(config.hasDefault).toBe(false);
expect(config.isSensitive).toBe(true);
expect(config.description).toBe("desc");
expect(config.rules.length).toBe(1);
});
it("_markOptional() sets isRequired=false", { timeout: 5000 }, () => {
const f = new ConcreteField();
f.markOptional();
expect(f._isRequired).toBe(false);
});
it("_markRequired() sets isRequired=true", { timeout: 5000 }, () => {
const f = new ConcreteField();
f.markOptional();
f.markRequired();
expect(f._isRequired).toBe(true);
expect(f._hasDefault).toBe(false);
});
it("_setDefault() sets default value", { timeout: 5000 }, () => {
const f = new ConcreteField();
f.setDefault("hello");
expect(f._hasDefault).toBe(true);
expect(f._defaultValue).toBe("hello");
expect(f._isRequired).toBe(true);
});
it("fluent chaining returns this", { timeout: 5000 }, () => {
const f = new ConcreteField();
const result = f.sensitive().describe("x");
expect(result).toBe(f);
});
});

View File

@@ -0,0 +1,81 @@
import type { ValidationRule } from "../../types/common.js";
import type { FieldConfig, RuleOptions } from "./field.types.js";
/**
* Abstract base class for all environment field builders.
*
* Provides shared functionality: required/optional flags,
* default values, sensitivity markers, descriptions, and
* custom validation rules.
*
* Subclasses must implement `coerce(raw: string): T` to
* convert raw environment strings into typed values.
*
* @typeParam T - The output value type produced by this field.
* @typeParam TRequired - Compile-time flag: `true` if the field always resolves.
*/
export abstract class BaseField<T, TRequired extends boolean = true> {
declare readonly _phantom: { type: T; required: TRequired };
_isRequired: boolean;
_hasDefault: boolean;
_defaultValue?: T;
_isSensitive: boolean;
_description?: string;
_rules: Array<ValidationRule<T>>;
constructor() {
this._isRequired = true;
this._hasDefault = false;
this._isSensitive = false;
this._rules = [];
}
/** Mark this field as carrying sensitive data (values masked in logs). */
sensitive(): this {
this._isSensitive = true;
return this;
}
/** Attach a human-readable description used in error messages and docs. */
describe(description: string): this {
this._description = description;
return this;
}
/** Add a custom validation rule executed after coercion. */
addRule(options: RuleOptions<T>): this {
this._rules.push(options);
return this;
}
/** Extract the runtime configuration snapshot for this field. */
getConfig(): FieldConfig<T> {
return {
isRequired: this._isRequired,
hasDefault: this._hasDefault,
defaultValue: this._defaultValue,
isSensitive: this._isSensitive,
description: this._description,
rules: [...this._rules],
};
}
/** Convert a raw environment string into the typed value. */
abstract coerce(raw: string): T;
protected _markRequired(): void {
this._isRequired = true;
this._hasDefault = false;
}
protected _markOptional(): void {
this._isRequired = false;
}
protected _setDefault(value: T): void {
this._hasDefault = true;
this._defaultValue = value;
this._isRequired = true;
}
}

View File

@@ -0,0 +1,18 @@
import type { ValidationRule } from "../../types/common.js";
/** Runtime configuration snapshot extracted from any BaseField. */
export interface FieldConfig<T = unknown> {
readonly isRequired: boolean;
readonly hasDefault: boolean;
readonly defaultValue?: T | undefined;
readonly isSensitive: boolean;
readonly description?: string | undefined;
readonly rules: ReadonlyArray<ValidationRule<T>>;
}
/** Options for adding a named validation rule via BaseField.addRule(). */
export interface RuleOptions<T = unknown> {
readonly name: string;
readonly message: string;
validate(value: T): boolean;
}

View File

@@ -0,0 +1,2 @@
export { BaseField } from "./field";
export type { FieldConfig, RuleOptions } from "./field.types";

View File

@@ -0,0 +1,8 @@
export { type CollectedErrors, createEnv, type EnvResult } from "./create-env";
export { BaseField, type FieldConfig, type RuleOptions } from "./field";
export {
maskSensitive,
type ValidateInput,
type ValidationResult,
validate,
} from "./validator";

View File

@@ -0,0 +1,2 @@
export { maskSensitive, validate } from "./validator";
export type { ValidateInput, ValidationResult } from "./validator.types";

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,173 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { bool } from "../../fields/boolean-field/boolean-field.js";
import { num } from "../../fields/number-field/number-field.js";
import { str } from "../../fields/string-field/string-field.js";
import { maskSensitive, validate } from "./validator.js";
describe("validate", () => {
it("passes when all fields resolve", { timeout: 5000 }, () => {
const result = validate({
schema: { NAME: str(), AGE: num() },
values: { NAME: "alice", AGE: "30" },
});
expect(result.success).toBe(true);
expect(result.data.NAME).toBe("alice");
expect(result.data.AGE).toBe(30);
expect(result.errors.length).toBe(0);
});
it("accumulates errors for all failing fields", { timeout: 5000 }, () => {
const result = validate({
schema: {
NAME: str(),
AGE: num(),
},
values: { NAME: "", AGE: "" },
});
// NAME is empty but str() doesn't enforce nonempty by default
// AGE empty → coercion fails
expect(result.errors.length).toBeGreaterThan(0);
});
it("returns missing required error", { timeout: 5000 }, () => {
const result = validate({
schema: { HOST: str() },
values: {},
});
expect(result.success).toBe(false);
expect(result.errors[0]?.key).toBe("HOST");
expect(result.errors[0]?.message).toContain("missing");
});
it("uses default value when raw is undefined", { timeout: 5000 }, () => {
const result = validate({
schema: { DEBUG: bool().default(false) },
values: {},
});
expect(result.success).toBe(true);
expect(result.data.DEBUG).toBe(false);
});
it("returns undefined for optional field with no value", {
timeout: 5000,
}, () => {
const result = validate({
schema: { EXTRA: str().optional() },
values: {},
});
expect(result.success).toBe(true);
expect(result.data.EXTRA).toBeUndefined();
});
it("applies prefix when looking up values", { timeout: 5000 }, () => {
const result = validate({
schema: { PORT: num() },
values: { APP_PORT: "4000" },
prefix: "APP_",
});
expect(result.success).toBe(true);
expect(result.data.PORT).toBe(4000);
});
it("returns error when rule fails", { timeout: 5000 }, () => {
const result = validate({
schema: { NAME: str().minLength(5) },
values: { NAME: "hi" },
});
expect(result.success).toBe(false);
expect(result.errors[0]?.key).toBe("NAME");
expect(result.errors[0]?.message).toContain("at least 5");
});
it("accumulates multiple field errors", { timeout: 5000 }, () => {
const result = validate({
schema: { A: str(), B: num() },
values: {},
});
expect(result.errors.length).toBe(2);
});
it("stops at first rule failure per field", { timeout: 5000 }, () => {
const result = validate({
schema: { NAME: str().minLength(10).maxLength(5) },
values: { NAME: "hi" },
});
// Only one error per field (first failing rule)
expect(result.errors.length).toBe(1);
});
it("handles non-Error coercion failures gracefully", {
timeout: 5000,
}, () => {
// Create a field whose coerce() throws a non-Error value
const field = str();
(field as unknown as { coerce: (raw: string) => string }).coerce = (
_raw: string
): string => {
throw "string error" as any;
};
const result = validate({ schema: { X: field }, values: { X: "bad" } });
expect(result.success).toBe(false);
expect(result.errors[0]?.message).toBe("Coercion failed");
});
});
describe("maskSensitive", () => {
it("masks sensitive values with ****", { timeout: 5000 }, () => {
const schema = { SECRET: str().sensitive(), PUBLIC: str() };
const data = { SECRET: "my-secret", PUBLIC: "public-value" };
const masked = maskSensitive(schema, data);
expect(masked.SECRET).toBe("****");
expect(masked.PUBLIC).toBe("public-value");
});
it("does not mask non-sensitive values", { timeout: 5000 }, () => {
const schema = { PORT: num() };
const data = { PORT: 3000 };
const masked = maskSensitive(schema, data);
expect(masked.PORT).toBe(3000);
});
it("handles keys not in schema", { timeout: 5000 }, () => {
const schema = { A: str() };
const data = { A: "hello", B: "extra" };
const masked = maskSensitive(schema, data);
expect(masked.A).toBe("hello");
expect(masked.B).toBe("extra");
});
});
describe("validate — property-based tests", () => {
it("if N rules fail, all N errors are accumulated via separate fields", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(fc.integer({ min: 1, max: 6 }), (n) => {
// Build n required string fields, all missing
const schema: Record<string, ReturnType<typeof str>> = {};
for (let i = 0; i < n; i++) {
schema[`FIELD_${i}`] = str();
}
const result = validate({ schema, values: {} });
expect(result.errors.length).toBe(n);
}),
{ numRuns: 50 }
);
});
it("maskSensitive never leaks sensitive raw values", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(fc.string({ minLength: 1 }), (rawSecret) => {
const schema = { SECRET: str().sensitive() };
const data = { SECRET: rawSecret };
const masked = maskSensitive(schema, data);
expect(masked.SECRET).not.toBe(rawSecret);
expect(masked.SECRET).toBe("****");
}),
{ numRuns: 100 }
);
});
});

View File

@@ -0,0 +1,103 @@
import { createEnvError } from "../../errors/env-error.js";
import type { AnyField, EnvError } from "../../types/common.js";
import type { ValidateInput, ValidationResult } from "./validator.types.js";
/**
* Process a single field against its raw value.
*
* Returns either the coerced value or an error object.
*/
function processField(
key: string,
field: AnyField,
raw: string | undefined
): { value?: unknown; error?: EnvError } {
if (raw === undefined || raw === "") {
if (field._hasDefault) {
return { value: field._defaultValue };
}
if (field._isRequired) {
return {
error: createEnvError(key, "Required variable is missing"),
};
}
return { value: undefined };
}
let coerced: unknown;
try {
coerced = field.coerce(raw);
} catch (err) {
const message = err instanceof Error ? err.message : "Coercion failed";
return { error: createEnvError(key, message, raw) };
}
for (const rule of field._rules) {
if (!rule.validate(coerced as never)) {
return {
error: createEnvError(key, rule.message, raw),
};
}
}
return { value: coerced };
}
/**
* Validates a set of environment variables against a schema.
*
* Iterates over every field in the schema, resolves the raw value
* from the provided values map (applying optional prefix), coerces
* each raw string, and runs all validation rules. All errors are
* accumulated before returning so the caller sees the full picture.
*
* @param input - The schema, values map, and optional prefix.
* @returns A result containing parsed data and any accumulated errors.
*/
export function validate(input: ValidateInput): ValidationResult {
const { schema, values, prefix } = input;
const errors: EnvError[] = [];
const data: Record<string, unknown> = {};
for (const [schemaKey, field] of Object.entries(schema)) {
const lookupKey = prefix ? `${prefix}${schemaKey}` : schemaKey;
const raw = values[lookupKey];
const { value, error } = processField(schemaKey, field, raw);
if (error) {
errors.push(error);
} else {
data[schemaKey] = value;
}
}
return {
success: errors.length === 0,
data,
errors,
};
}
/**
* Replaces sensitive field values with `****` in a data record.
*
* Used before logging or surfacing parsed env values to prevent
* secrets from leaking into logs or error messages.
*
* @param schema - The env schema providing sensitivity flags.
* @param data - The parsed data record to mask.
* @returns A new record with sensitive values replaced by `****`.
*/
export function maskSensitive(
schema: Record<string, AnyField>,
data: Record<string, unknown>
): Record<string, unknown> {
const masked: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
const field = schema[key];
masked[key] = field?._isSensitive ? "****" : value;
}
return masked;
}

View File

@@ -0,0 +1,15 @@
import type { EnvError, EnvSchema } from "../../types/common.js";
/** The result of validating a full set of environment variables. */
export interface ValidationResult<T = Record<string, unknown>> {
readonly success: boolean;
readonly data: T;
readonly errors: ReadonlyArray<EnvError>;
}
/** Input to the validate() function. */
export interface ValidateInput {
readonly schema: EnvSchema;
readonly values: Record<string, string | undefined>;
readonly prefix?: string;
}

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,158 @@
import { SuaveplanError } from "@suaveplan/error";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CoercionError,
createEnvError,
DotenvParseError,
EnvBaseError,
EnvValidationError,
MissingEnvError,
} from "./env-error.js";
import { ENV_CODES } from "./error-codes.js";
describe("ENV_CODES", () => {
it("all codes have ENV. prefix", { timeout: 5000 }, () => {
for (const code of Object.values(ENV_CODES)) {
expect(code).toMatch(/^ENV\./);
}
});
it("has expected codes", { timeout: 5000 }, () => {
expect(ENV_CODES.VALIDATION_FAILED).toBe("ENV.VALIDATION_FAILED");
expect(ENV_CODES.MISSING_REQUIRED).toBe("ENV.MISSING_REQUIRED");
expect(ENV_CODES.INVALID_FORMAT).toBe("ENV.INVALID_FORMAT");
expect(ENV_CODES.PARSE_ERROR).toBe("ENV.PARSE_ERROR");
expect(ENV_CODES.COERCE_ERROR).toBe("ENV.COERCE_ERROR");
});
});
describe("EnvValidationError", () => {
it("extends SuaveplanError and EnvBaseError", { timeout: 5000 }, () => {
const err = new EnvValidationError([
{ key: "PORT", message: "Required variable is missing" },
]);
expect(err).toBeInstanceOf(SuaveplanError);
expect(err).toBeInstanceOf(EnvBaseError);
expect(err).toBeInstanceOf(EnvValidationError);
});
it("sets code to VALIDATION_FAILED", { timeout: 5000 }, () => {
const err = new EnvValidationError([]);
expect(err.code).toBe(ENV_CODES.VALIDATION_FAILED);
});
it("stores errors array", { timeout: 5000 }, () => {
const errors = [
{ key: "HOST", message: "Required" },
{ key: "PORT", message: "Invalid" },
];
const err = new EnvValidationError(errors);
expect(err.errors).toEqual(errors);
expect(err.errors.length).toBe(2);
});
it("message includes all field errors", { timeout: 5000 }, () => {
const err = new EnvValidationError([
{ key: "PORT", message: "Must be a number" },
]);
expect(err.message).toContain("PORT");
expect(err.message).toContain("Must be a number");
});
it("name is EnvValidationError", { timeout: 5000 }, () => {
const err = new EnvValidationError([]);
expect(err.name).toBe("EnvValidationError");
});
});
describe("MissingEnvError", () => {
it("extends EnvBaseError", { timeout: 5000 }, () => {
const err = new MissingEnvError("DATABASE_URL");
expect(err).toBeInstanceOf(EnvBaseError);
expect(err).toBeInstanceOf(MissingEnvError);
});
it("sets code to MISSING_REQUIRED", { timeout: 5000 }, () => {
const err = new MissingEnvError("SECRET");
expect(err.code).toBe(ENV_CODES.MISSING_REQUIRED);
});
it("stores key", { timeout: 5000 }, () => {
const err = new MissingEnvError("MY_VAR");
expect(err.key).toBe("MY_VAR");
});
it("message includes variable name", { timeout: 5000 }, () => {
const err = new MissingEnvError("MY_VAR");
expect(err.message).toContain("MY_VAR");
});
});
describe("CoercionError", () => {
it("extends EnvBaseError", { timeout: 5000 }, () => {
const err = new CoercionError("PORT", "abc", "number");
expect(err).toBeInstanceOf(EnvBaseError);
expect(err).toBeInstanceOf(CoercionError);
});
it("sets code to COERCE_ERROR", { timeout: 5000 }, () => {
const err = new CoercionError("", "bad", "number");
expect(err.code).toBe(ENV_CODES.COERCE_ERROR);
});
it("stores key, rawValue, targetType", { timeout: 5000 }, () => {
const err = new CoercionError("PORT", "abc", "number");
expect(err.key).toBe("PORT");
expect(err.rawValue).toBe("abc");
expect(err.targetType).toBe("number");
});
it("message includes rawValue and targetType", { timeout: 5000 }, () => {
const err = new CoercionError("", "abc", "number");
expect(err.message).toContain("abc");
expect(err.message).toContain("number");
});
it("message includes key when provided", { timeout: 5000 }, () => {
const err = new CoercionError("PORT", "abc", "number");
expect(err.message).toContain("PORT");
});
});
describe("DotenvParseError", () => {
it("extends EnvBaseError", { timeout: 5000 }, () => {
const err = new DotenvParseError("unexpected token", 5);
expect(err).toBeInstanceOf(EnvBaseError);
expect(err).toBeInstanceOf(DotenvParseError);
});
it("sets code to PARSE_ERROR", { timeout: 5000 }, () => {
const err = new DotenvParseError("bad line", 1);
expect(err.code).toBe(ENV_CODES.PARSE_ERROR);
});
it("stores lineNumber", { timeout: 5000 }, () => {
const err = new DotenvParseError("bad line", 42);
expect(err.lineNumber).toBe(42);
});
it("message includes lineNumber and message", { timeout: 5000 }, () => {
const err = new DotenvParseError("unexpected token", 7);
expect(err.message).toContain("7");
expect(err.message).toContain("unexpected token");
});
});
describe("createEnvError", () => {
it("returns a plain EnvError value object", { timeout: 5000 }, () => {
const ev = createEnvError("PORT", "Required", "bad");
expect(ev.key).toBe("PORT");
expect(ev.message).toBe("Required");
expect(ev.value).toBe("bad");
});
it("value is optional", { timeout: 5000 }, () => {
const ev = createEnvError("HOST", "Missing");
expect(ev.value).toBeUndefined();
});
});

View File

@@ -0,0 +1,114 @@
import { SuaveplanError, type SuaveplanErrorOptions } from "@suaveplan/error";
import type { EnvError as EnvErrorValue } from "../types/common.js";
import { ENV_CODES, type EnvCode } from "./error-codes.js";
/**
* Abstract base class for all @suaveplan/env error classes.
*
* Extends SuaveplanError with the ENV code namespace. All concrete
* error classes in this package extend EnvBaseError.
*/
export abstract class EnvBaseError extends SuaveplanError<EnvCode> {
protected constructor(
code: EnvCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
}
}
/**
* Thrown when one or more fields fail validation and
* `onError` is set to `'throw'` (the default).
*/
export class EnvValidationError extends EnvBaseError {
readonly errors: ReadonlyArray<EnvErrorValue>;
constructor(
errors: ReadonlyArray<EnvErrorValue>,
options?: SuaveplanErrorOptions
) {
const lines = errors.map((e) => ` - ${e.key}: ${e.message}`);
super(
ENV_CODES.VALIDATION_FAILED,
`Environment validation failed:\n${lines.join("\n")}`,
options
);
this.errors = errors;
}
}
/**
* Thrown when a required environment variable is absent and
* has no default value.
*/
export class MissingEnvError extends EnvBaseError {
readonly key: string;
constructor(key: string, options?: SuaveplanErrorOptions) {
super(
ENV_CODES.MISSING_REQUIRED,
`Missing required environment variable: ${key}`,
options
);
this.key = key;
}
}
/**
* Thrown when a raw string cannot be coerced to the expected type.
*/
export class CoercionError extends EnvBaseError {
readonly key: string;
readonly rawValue: string;
readonly targetType: string;
constructor(
key: string,
rawValue: string,
targetType: string,
options?: SuaveplanErrorOptions
) {
super(
ENV_CODES.COERCE_ERROR,
`Cannot coerce "${rawValue}" to ${targetType}${key ? ` for variable ${key}` : ""}`,
options
);
this.key = key;
this.rawValue = rawValue;
this.targetType = targetType;
}
}
/**
* Thrown when a dotenv file cannot be parsed due to malformed syntax.
*/
export class DotenvParseError extends EnvBaseError {
readonly lineNumber: number;
constructor(
message: string,
lineNumber: number,
options?: SuaveplanErrorOptions
) {
super(
ENV_CODES.PARSE_ERROR,
`Dotenv parse error at line ${lineNumber}: ${message}`,
options
);
this.lineNumber = lineNumber;
}
}
/** Creates a standardised EnvError value object (not a class instance). */
export function createEnvError(
key: string,
message: string,
value?: string
): EnvErrorValue {
if (value !== undefined) {
return { key, message, value };
}
return { key, message };
}

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,18 @@
import { defineCodes } from "@suaveplan/error";
/**
* Registered error codes for the @suaveplan/env package.
*
* All codes live under the `ENV` namespace and are produced by
* `defineCodes`, so the return type is fully literal-inferred.
*/
export const ENV_CODES = defineCodes("ENV", [
"VALIDATION_FAILED",
"MISSING_REQUIRED",
"INVALID_FORMAT",
"PARSE_ERROR",
"COERCE_ERROR",
] as const);
/** Union of every ENV error code string. */
export type EnvCode = (typeof ENV_CODES)[keyof typeof ENV_CODES];

View File

@@ -0,0 +1,9 @@
export {
CoercionError,
createEnvError,
DotenvParseError,
EnvBaseError,
EnvValidationError,
MissingEnvError,
} from "./env-error";
export { ENV_CODES, type EnvCode } from "./error-codes";

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,82 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CoercionError } from "../../errors/env-error.js";
import { num } from "../number-field/number-field.js";
import { str } from "../string-field/string-field.js";
import { ArrayField, array } from "./array-field.js";
describe("array / ArrayField", () => {
it("factory returns ArrayField instance", { timeout: 5000 }, () => {
expect(array(str())).toBeInstanceOf(ArrayField);
});
it("splits on comma by default", { timeout: 5000 }, () => {
expect(array(str()).coerce("a,b,c")).toEqual(["a", "b", "c"]);
});
it("trims items by default", { timeout: 5000 }, () => {
expect(array(str()).coerce(" a , b , c ")).toEqual(["a", "b", "c"]);
});
it("filters empty strings by default", { timeout: 5000 }, () => {
expect(array(str()).coerce("a,,b")).toEqual(["a", "b"]);
});
it("empty raw returns empty array", { timeout: 5000 }, () => {
expect(array(str()).coerce("")).toEqual([]);
expect(array(str()).coerce(" ")).toEqual([]);
});
it("coerces items with number field", { timeout: 5000 }, () => {
expect(array(num()).coerce("1,2,3")).toEqual([1, 2, 3]);
});
it("throws CoercionError when item coercion fails", {
timeout: 5000,
}, () => {
expect(() => array(num()).coerce("1,abc,3")).toThrow(CoercionError);
});
it("separator() uses custom delimiter", { timeout: 5000 }, () => {
expect(array(str()).separator("|").coerce("a|b|c")).toEqual([
"a",
"b",
"c",
]);
});
it("noTrim() preserves whitespace in items", { timeout: 5000 }, () => {
expect(array(str()).noTrim().coerce(" a , b ")).toEqual([" a ", " b "]);
});
it("keepEmpty() preserves empty strings", { timeout: 5000 }, () => {
const result = array(str()).keepEmpty().coerce("a,,b");
expect(result).toContain("");
});
it("minItems() rule", { timeout: 5000 }, () => {
const f = array(str()).minItems(2);
expect(f._rules[0]!.validate(["a", "b"])).toBe(true);
expect(f._rules[0]!.validate(["a"])).toBe(false);
});
it("maxItems() rule", { timeout: 5000 }, () => {
const f = array(str()).maxItems(2);
expect(f._rules[0]!.validate(["a", "b"])).toBe(true);
expect(f._rules[0]!.validate(["a", "b", "c"])).toBe(false);
});
it("optional() marks optional", { timeout: 5000 }, () => {
const f = array(str()).optional();
expect(f._isRequired).toBe(false);
});
it("default() sets default", { timeout: 5000 }, () => {
const f = array(str()).default(["x"]);
expect(f._defaultValue).toEqual(["x"]);
});
it("required() marks required", { timeout: 5000 }, () => {
const f = array(str()).optional().required();
expect(f._isRequired).toBe(true);
});
});

View File

@@ -0,0 +1,96 @@
import { DEFAULT_ARRAY_SEPARATOR } from "../../constants/index.js";
import { BaseField } from "../../core/field/field.js";
import type { AnyField } from "../../types/common.js";
/**
* A field builder for array environment variables.
*
* Splits a delimited string and coerces each element through a
* provided item field builder. Supports custom separators, trim
* control, and min/max item count rules.
*
* @typeParam T - The type of each array element.
*/
export class ArrayField<T, TRequired extends boolean = true> extends BaseField<
T[],
TRequired
> {
protected readonly _itemField: AnyField<T>;
protected _separator: string = DEFAULT_ARRAY_SEPARATOR;
protected _trimItems = true;
protected _filterEmpty = true;
constructor(itemField: AnyField<T>) {
super();
this._itemField = itemField;
}
required(): ArrayField<T, true> {
this._markRequired();
return this as unknown as ArrayField<T, true>;
}
optional(): ArrayField<T, false> {
this._markOptional();
return this as unknown as ArrayField<T, false>;
}
default(value: T[]): ArrayField<T, true> {
this._setDefault(value);
return this as unknown as ArrayField<T, true>;
}
/** Override the separator used to split the raw string. */
separator(sep: string): this {
this._separator = sep;
return this;
}
/** Disable automatic whitespace trimming of individual items. */
noTrim(): this {
this._trimItems = false;
return this;
}
/** Keep empty strings after splitting instead of filtering them out. */
keepEmpty(): this {
this._filterEmpty = false;
return this;
}
/** Require at least `n` items in the resulting array. */
minItems(n: number): this {
this._rules.push({
name: "minItems",
message: `Must have at least ${n} items`,
validate: (v) => v.length >= n,
});
return this;
}
/** Require at most `n` items in the resulting array. */
maxItems(n: number): this {
this._rules.push({
name: "maxItems",
message: `Must have at most ${n} items`,
validate: (v) => v.length <= n,
});
return this;
}
coerce(raw: string): T[] {
let parts = raw.split(this._separator);
if (this._trimItems) {
parts = parts.map((p) => p.trim());
}
if (this._filterEmpty) {
parts = parts.filter((p) => p.length > 0);
}
return parts.map((p) => this._itemField.coerce(p));
}
}
/** Create an array field builder with an item field applied to each element. */
export function array<T>(itemField: AnyField<T>): ArrayField<T, true> {
return new ArrayField(itemField);
}

View File

@@ -0,0 +1,7 @@
import type { AnyField } from "../../types/common.js";
/**
* A minimal field descriptor applied to each element of an ArrayField.
* Typically a concrete field builder like `str()`, `num()`, etc.
*/
export type ItemField<T> = AnyField<T>;

View File

@@ -0,0 +1,2 @@
export { ArrayField, array } from "./array-field";
export type { ItemField } from "./array-field.types";

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,59 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FALSY_VALUES, TRUTHY_VALUES } from "../../constants/index.js";
import { CoercionError } from "../../errors/env-error.js";
import { BooleanField, bool } from "./boolean-field.js";
describe("bool / BooleanField", () => {
it("factory returns BooleanField instance", { timeout: 5000 }, () => {
expect(bool()).toBeInstanceOf(BooleanField);
});
it("coerces all TRUTHY_VALUES to true", { timeout: 5000 }, () => {
for (const v of TRUTHY_VALUES) {
expect(bool().coerce(v)).toBe(true);
}
});
it("coerces all FALSY_VALUES to false", { timeout: 5000 }, () => {
for (const v of FALSY_VALUES) {
expect(bool().coerce(v)).toBe(false);
}
});
it("coerces case-insensitively", { timeout: 5000 }, () => {
expect(bool().coerce("TRUE")).toBe(true);
expect(bool().coerce("FALSE")).toBe(false);
expect(bool().coerce("YES")).toBe(true);
});
it("throws CoercionError for unknown value", { timeout: 5000 }, () => {
expect(() => bool().coerce("maybe")).toThrow(CoercionError);
});
it("truthy() overrides truthy set", { timeout: 5000 }, () => {
const f = bool().truthy(["yep"]);
expect(f.coerce("yep")).toBe(true);
expect(() => f.coerce("true")).toThrow(CoercionError);
});
it("falsy() overrides falsy set", { timeout: 5000 }, () => {
const f = bool().falsy(["nope"]);
expect(f.coerce("nope")).toBe(false);
expect(() => f.coerce("false")).toThrow(CoercionError);
});
it("optional() marks optional", { timeout: 5000 }, () => {
const f = bool().optional();
expect(f._isRequired).toBe(false);
});
it("default() sets boolean default", { timeout: 5000 }, () => {
const f = bool().default(true);
expect(f._defaultValue).toBe(true);
});
it("required() marks required", { timeout: 5000 }, () => {
const f = bool().optional().required();
expect(f._isRequired).toBe(true);
});
});

View File

@@ -0,0 +1,60 @@
import { FALSY_VALUES, TRUTHY_VALUES } from "../../constants/index.js";
import { BaseField } from "../../core/field/field.js";
import { CoercionError } from "../../errors/env-error.js";
/**
* A field builder for boolean environment variables.
*
* Recognises common truthy/falsy string representations and
* supports overriding the accepted value sets.
*/
export class BooleanField<TRequired extends boolean = true> extends BaseField<
boolean,
TRequired
> {
protected _truthyValues: Set<string> = new Set(TRUTHY_VALUES);
protected _falsyValues: Set<string> = new Set(FALSY_VALUES);
required(): BooleanField<true> {
this._markRequired();
return this as unknown as BooleanField<true>;
}
optional(): BooleanField<false> {
this._markOptional();
return this as unknown as BooleanField<false>;
}
default(value: boolean): BooleanField<true> {
this._setDefault(value);
return this as unknown as BooleanField<true>;
}
/** Override the set of strings coerced to `true`. */
truthy(values: string[]): this {
this._truthyValues = new Set(values.map((v) => v.toLowerCase()));
return this;
}
/** Override the set of strings coerced to `false`. */
falsy(values: string[]): this {
this._falsyValues = new Set(values.map((v) => v.toLowerCase()));
return this;
}
coerce(raw: string): boolean {
const lower = raw.trim().toLowerCase();
if (this._truthyValues.has(lower)) return true;
if (this._falsyValues.has(lower)) return false;
throw new CoercionError(
"",
raw,
`boolean (expected one of: ${[...this._truthyValues, ...this._falsyValues].join(", ")})`
);
}
}
/** Create a boolean field builder. */
export function bool(): BooleanField<true> {
return new BooleanField();
}

View File

@@ -0,0 +1 @@
export { BooleanField, bool } from "./boolean-field";

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,50 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CoercionError } from "../../errors/env-error.js";
import { EmailField, email } from "./email-field.js";
describe("email / EmailField", () => {
it("factory returns EmailField instance", { timeout: 5000 }, () => {
expect(email()).toBeInstanceOf(EmailField);
});
it("coerce accepts valid email", { timeout: 5000 }, () => {
expect(email().coerce("user@example.com")).toBe("user@example.com");
});
it("coerce trims whitespace", { timeout: 5000 }, () => {
expect(email().coerce(" user@example.com ")).toBe("user@example.com");
});
it("coerce throws for invalid email", { timeout: 5000 }, () => {
expect(() => email().coerce("not-an-email")).toThrow(CoercionError);
expect(() => email().coerce("@example.com")).toThrow(CoercionError);
expect(() => email().coerce("user@")).toThrow(CoercionError);
});
it("normalise() lowercases the email", { timeout: 5000 }, () => {
expect(email().normalise().coerce("User@EXAMPLE.COM")).toBe(
"user@example.com"
);
});
it("pattern() uses custom regex", { timeout: 5000 }, () => {
const f = email().pattern(/^admin@/);
expect(f.coerce("admin@example.com")).toBe("admin@example.com");
expect(() => f.coerce("user@example.com")).toThrow(CoercionError);
});
it("optional() marks optional", { timeout: 5000 }, () => {
const f = email().optional();
expect(f._isRequired).toBe(false);
});
it("default() sets default", { timeout: 5000 }, () => {
const f = email().default("admin@example.com");
expect(f._defaultValue).toBe("admin@example.com");
});
it("required() marks required", { timeout: 5000 }, () => {
const f = email().optional().required();
expect(f._isRequired).toBe(true);
});
});

View File

@@ -0,0 +1,61 @@
import { EMAIL_PATTERN } from "../../constants/index.js";
import { BaseField } from "../../core/field/field.js";
import { CoercionError } from "../../errors/env-error.js";
/**
* A field builder for email address environment variables.
*
* Validates that the value looks like a valid email using a
* basic pattern check covering the vast majority of real addresses.
* Supports custom validation patterns and lowercase normalisation.
*/
export class EmailField<TRequired extends boolean = true> extends BaseField<
string,
TRequired
> {
protected _pattern: RegExp = EMAIL_PATTERN;
protected _normalise = false;
required(): EmailField<true> {
this._markRequired();
return this as unknown as EmailField<true>;
}
optional(): EmailField<false> {
this._markOptional();
return this as unknown as EmailField<false>;
}
default(value: string): EmailField<true> {
this._setDefault(value);
return this as unknown as EmailField<true>;
}
/** Use a custom email validation pattern. */
pattern(regex: RegExp): this {
this._pattern = regex;
return this;
}
/** Normalise the email to lowercase before returning. */
normalise(): this {
this._normalise = true;
return this;
}
coerce(raw: string): string {
const trimmed = raw.trim();
const value = this._normalise ? trimmed.toLowerCase() : trimmed;
if (!this._pattern.test(value)) {
throw new CoercionError("", trimmed, "email address");
}
return value;
}
}
/** Create an email field builder. */
export function email(): EmailField<true> {
return new EmailField();
}

View File

@@ -0,0 +1 @@
export { EmailField, email } from "./email-field";

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,49 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CoercionError } from "../../errors/env-error.js";
import { EnumField, enums } from "./enum-field.js";
describe("enums / EnumField", () => {
const VALUES = ["development", "production", "test"] as const;
it("factory returns EnumField instance", { timeout: 5000 }, () => {
expect(enums(VALUES)).toBeInstanceOf(EnumField);
});
it("coerce returns matching value", { timeout: 5000 }, () => {
expect(enums(VALUES).coerce("development")).toBe("development");
expect(enums(VALUES).coerce("production")).toBe("production");
});
it("throws CoercionError for invalid value", { timeout: 5000 }, () => {
expect(() => enums(VALUES).coerce("staging")).toThrow(CoercionError);
});
it("trims whitespace from raw", { timeout: 5000 }, () => {
expect(enums(VALUES).coerce(" development ")).toBe("development");
});
it("caseInsensitive() allows uppercase input", { timeout: 5000 }, () => {
const f = enums(VALUES).caseInsensitive();
expect(f.coerce("DEVELOPMENT")).toBe("development");
});
it("getAllowedValues() returns allowed list", { timeout: 5000 }, () => {
const f = enums(VALUES);
expect(f.getAllowedValues()).toEqual([...VALUES]);
});
it("optional() marks optional", { timeout: 5000 }, () => {
const f = enums(VALUES).optional();
expect(f._isRequired).toBe(false);
});
it("default() sets default", { timeout: 5000 }, () => {
const f = enums(VALUES).default("test");
expect(f._defaultValue).toBe("test");
});
it("required() marks required", { timeout: 5000 }, () => {
const f = enums(VALUES).optional().required();
expect(f._isRequired).toBe(true);
});
});

View File

@@ -0,0 +1,74 @@
import { BaseField } from "../../core/field/field.js";
import { CoercionError } from "../../errors/env-error.js";
/**
* A field builder for enum (string-union) environment variables.
*
* Constrains the value to a fixed set of allowed strings and
* infers the output type as the union of those string literals.
*
* @typeParam T - The string literal union of allowed values.
*/
export class EnumField<
T extends string,
TRequired extends boolean = true,
> extends BaseField<T, TRequired> {
protected readonly _allowedValues: ReadonlyArray<T>;
protected _caseInsensitive = false;
constructor(values: ReadonlyArray<T>) {
super();
this._allowedValues = values;
}
required(): EnumField<T, true> {
this._markRequired();
return this as unknown as EnumField<T, true>;
}
optional(): EnumField<T, false> {
this._markOptional();
return this as unknown as EnumField<T, false>;
}
default(value: T): EnumField<T, true> {
this._setDefault(value);
return this as unknown as EnumField<T, true>;
}
/** Allow case-insensitive matching against the allowed values. */
caseInsensitive(): this {
this._caseInsensitive = true;
return this;
}
/** Return the list of allowed values for documentation purposes. */
getAllowedValues(): ReadonlyArray<T> {
return this._allowedValues;
}
coerce(raw: string): T {
const trimmed = raw.trim();
const compare = this._caseInsensitive ? trimmed.toLowerCase() : trimmed;
for (const allowed of this._allowedValues) {
const target = this._caseInsensitive
? allowed.toLowerCase()
: allowed;
if (compare === target) return allowed;
}
throw new CoercionError(
"",
raw,
`one of: ${this._allowedValues.join(", ")}`
);
}
}
/** Create an enum field builder from a const tuple of allowed values. */
export function enums<const T extends readonly string[]>(
values: T
): EnumField<T[number], true> {
return new EnumField([...values]);
}

View File

@@ -0,0 +1,2 @@
/** A readonly tuple of string literals used to constrain EnumField. */
export type EnumValues = readonly string[];

View File

@@ -0,0 +1,2 @@
export { EnumField, enums } from "./enum-field";
export type { EnumValues } from "./enum-field.types";

View File

@@ -0,0 +1,9 @@
export { ArrayField, array, type ItemField } from "./array-field";
export { BooleanField, bool } from "./boolean-field";
export { EmailField, email } from "./email-field";
export { EnumField, type EnumValues, enums } from "./enum-field";
export { JsonField, type JsonValidator, json } from "./json-field";
export { NumberField, num } from "./number-field";
export { PortField, port } from "./port-field";
export { StringField, str } from "./string-field";
export { UrlField, url } from "./url-field";

View File

@@ -0,0 +1,2 @@
export { JsonField, json } from "./json-field";
export type { JsonValidator } from "./json-field.types";

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,62 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CoercionError } from "../../errors/env-error.js";
import { JsonField, json } from "./json-field.js";
describe("json / JsonField", () => {
it("factory returns JsonField instance", { timeout: 5000 }, () => {
expect(json()).toBeInstanceOf(JsonField);
});
it("coerce parses valid JSON object", { timeout: 5000 }, () => {
const result = json().coerce('{"key":"value"}');
expect(result).toEqual({ key: "value" });
});
it("coerce parses JSON array", { timeout: 5000 }, () => {
const result = json().coerce("[1,2,3]");
expect(result).toEqual([1, 2, 3]);
});
it("coerce parses JSON primitive", { timeout: 5000 }, () => {
expect(json().coerce("42")).toBe(42);
expect(json().coerce('"hello"')).toBe("hello");
expect(json().coerce("true")).toBe(true);
expect(json().coerce("null")).toBeNull();
});
it("coerce trims whitespace", { timeout: 5000 }, () => {
expect(json().coerce(' {"x": 1} ')).toEqual({ x: 1 });
});
it("coerce throws for invalid JSON", { timeout: 5000 }, () => {
expect(() => json().coerce("{bad}")).toThrow(CoercionError);
expect(() => json().coerce("undefined")).toThrow(CoercionError);
});
it("validator() rejects when type guard fails", { timeout: 5000 }, () => {
const f = json<{ name: string }>().validator(
(v): v is { name: string } =>
typeof v === "object" &&
v !== null &&
"name" in v &&
typeof (v as { name: unknown }).name === "string"
);
expect(f.coerce('{"name":"alice"}')).toEqual({ name: "alice" });
expect(() => f.coerce('{"age":30}')).toThrow(CoercionError);
});
it("optional() marks optional", { timeout: 5000 }, () => {
const f = json().optional();
expect(f._isRequired).toBe(false);
});
it("default() sets default", { timeout: 5000 }, () => {
const f = json<{ x: number }>().default({ x: 1 });
expect(f._defaultValue).toEqual({ x: 1 });
});
it("required() marks required", { timeout: 5000 }, () => {
const f = json().optional().required();
expect(f._isRequired).toBe(true);
});
});

View File

@@ -0,0 +1,61 @@
import { BaseField } from "../../core/field/field.js";
import { CoercionError } from "../../errors/env-error.js";
import type { JsonValidator } from "./json-field.types.js";
/**
* A field builder for JSON environment variables.
*
* Parses the raw string as JSON and optionally validates the parsed
* data with a type-guard function to narrow the output type.
*
* @typeParam T - The expected shape of the parsed JSON value.
*/
export class JsonField<
T = unknown,
TRequired extends boolean = true,
> extends BaseField<T, TRequired> {
protected _validator?: JsonValidator<T>;
required(): JsonField<T, true> {
this._markRequired();
return this as unknown as JsonField<T, true>;
}
optional(): JsonField<T, false> {
this._markOptional();
return this as unknown as JsonField<T, false>;
}
default(value: T): JsonField<T, true> {
this._setDefault(value);
return this as unknown as JsonField<T, true>;
}
/** Add a runtime type guard to validate the parsed JSON shape. */
validator(fn: JsonValidator<T>): this {
this._validator = fn;
return this;
}
coerce(raw: string): T {
const trimmed = raw.trim();
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
throw new CoercionError("", trimmed, "JSON");
}
if (this._validator && !this._validator(parsed)) {
throw new CoercionError("", trimmed, "JSON (failed validation)");
}
return parsed as T;
}
}
/** Create a JSON field builder, optionally with a type-guard validator. */
export function json<T = unknown>(): JsonField<T, true> {
return new JsonField<T, true>();
}

View File

@@ -0,0 +1,7 @@
/**
* A type-guard function used with JsonField to validate the shape
* of parsed JSON at runtime.
*
* @typeParam T - The expected shape of the parsed JSON.
*/
export type JsonValidator<T> = (parsed: unknown) => parsed is T;

View File

@@ -0,0 +1 @@
export { NumberField, num } from "./number-field";

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,71 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CoercionError } from "../../errors/env-error.js";
import { NumberField, num } from "./number-field.js";
describe("num / NumberField", () => {
it("factory returns NumberField instance", { timeout: 5000 }, () => {
expect(num()).toBeInstanceOf(NumberField);
});
it("coerce parses integer string", { timeout: 5000 }, () => {
expect(num().coerce("42")).toBe(42);
});
it("coerce parses float string", { timeout: 5000 }, () => {
expect(num().coerce("3.14")).toBeCloseTo(3.14);
});
it("coerce throws for empty string", { timeout: 5000 }, () => {
expect(() => num().coerce("")).toThrow(CoercionError);
});
it("coerce throws for NaN input", { timeout: 5000 }, () => {
expect(() => num().coerce("abc")).toThrow(CoercionError);
});
it("min() adds rule", { timeout: 5000 }, () => {
const f = num().min(5);
expect(f._rules[0]!.validate(5)).toBe(true);
expect(f._rules[0]!.validate(4)).toBe(false);
});
it("max() adds rule", { timeout: 5000 }, () => {
const f = num().max(10);
expect(f._rules[0]!.validate(10)).toBe(true);
expect(f._rules[0]!.validate(11)).toBe(false);
});
it("integer() adds rule", { timeout: 5000 }, () => {
const f = num().integer();
expect(f._rules[0]!.validate(5)).toBe(true);
expect(f._rules[0]!.validate(5.5)).toBe(false);
});
it("positive() adds rule", { timeout: 5000 }, () => {
const f = num().positive();
expect(f._rules[0]!.validate(1)).toBe(true);
expect(f._rules[0]!.validate(0)).toBe(false);
expect(f._rules[0]!.validate(-1)).toBe(false);
});
it("nonNegative() adds rule", { timeout: 5000 }, () => {
const f = num().nonNegative();
expect(f._rules[0]!.validate(0)).toBe(true);
expect(f._rules[0]!.validate(-1)).toBe(false);
});
it("optional() marks optional", { timeout: 5000 }, () => {
const f = num().optional();
expect(f._isRequired).toBe(false);
});
it("default() sets default", { timeout: 5000 }, () => {
const f = num().default(99);
expect(f._defaultValue).toBe(99);
});
it("required() marks required", { timeout: 5000 }, () => {
const f = num().optional().required();
expect(f._isRequired).toBe(true);
});
});

View File

@@ -0,0 +1,95 @@
import { BaseField } from "../../core/field/field.js";
import { CoercionError } from "../../errors/env-error.js";
/**
* A field builder for numeric environment variables.
*
* Parses raw strings to `number` and supports min/max range,
* integer, positive, and non-negative constraints.
*/
export class NumberField<TRequired extends boolean = true> extends BaseField<
number,
TRequired
> {
required(): NumberField<true> {
this._markRequired();
return this as unknown as NumberField<true>;
}
optional(): NumberField<false> {
this._markOptional();
return this as unknown as NumberField<false>;
}
default(value: number): NumberField<true> {
this._setDefault(value);
return this as unknown as NumberField<true>;
}
/** Require the value to be at least `n`. */
min(n: number): this {
this._rules.push({
name: "min",
message: `Must be at least ${n}`,
validate: (v) => v >= n,
});
return this;
}
/** Require the value to be at most `n`. */
max(n: number): this {
this._rules.push({
name: "max",
message: `Must be at most ${n}`,
validate: (v) => v <= n,
});
return this;
}
/** Require the value to be an integer. */
integer(): this {
this._rules.push({
name: "integer",
message: "Must be an integer",
validate: (v) => Number.isInteger(v),
});
return this;
}
/** Require the value to be strictly positive (> 0). */
positive(): this {
this._rules.push({
name: "positive",
message: "Must be positive",
validate: (v) => v > 0,
});
return this;
}
/** Require the value to be non-negative (>= 0). */
nonNegative(): this {
this._rules.push({
name: "nonNegative",
message: "Must be non-negative",
validate: (v) => v >= 0,
});
return this;
}
coerce(raw: string): number {
const trimmed = raw.trim();
if (trimmed === "") {
throw new CoercionError("", trimmed, "number");
}
const value = Number(trimmed);
if (Number.isNaN(value)) {
throw new CoercionError("", trimmed, "number");
}
return value;
}
}
/** Create a number field builder. */
export function num(): NumberField<true> {
return new NumberField();
}

View File

@@ -0,0 +1 @@
export { PortField, port } from "./port-field";

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,55 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { PORT_MAX, PORT_MIN } from "../../constants/index.js";
import { CoercionError } from "../../errors/env-error.js";
import { PortField, port } from "./port-field.js";
describe("port / PortField", () => {
it("factory returns PortField instance", { timeout: 5000 }, () => {
expect(port()).toBeInstanceOf(PortField);
});
it("coerce parses valid port", { timeout: 5000 }, () => {
expect(port().coerce("3000")).toBe(3000);
expect(port().coerce("80")).toBe(80);
expect(port().coerce("65535")).toBe(PORT_MAX);
expect(port().coerce("1")).toBe(PORT_MIN);
});
it("coerce throws for non-integer", { timeout: 5000 }, () => {
expect(() => port().coerce("3000.5")).toThrow(CoercionError);
expect(() => port().coerce("abc")).toThrow(CoercionError);
});
it("coerce throws for out-of-range port", { timeout: 5000 }, () => {
expect(() => port().coerce("0")).toThrow(CoercionError);
expect(() => port().coerce("65536")).toThrow(CoercionError);
expect(() => port().coerce("-1")).toThrow(CoercionError);
});
it("min() restricts minimum", { timeout: 5000 }, () => {
const f = port().min(1024);
expect(() => f.coerce("80")).toThrow(CoercionError);
expect(f.coerce("1024")).toBe(1024);
});
it("max() restricts maximum", { timeout: 5000 }, () => {
const f = port().max(8080);
expect(() => f.coerce("8081")).toThrow(CoercionError);
expect(f.coerce("8080")).toBe(8080);
});
it("optional() marks optional", { timeout: 5000 }, () => {
const f = port().optional();
expect(f._isRequired).toBe(false);
});
it("default() sets default", { timeout: 5000 }, () => {
const f = port().default(3000);
expect(f._defaultValue).toBe(3000);
});
it("required() marks required", { timeout: 5000 }, () => {
const f = port().optional().required();
expect(f._isRequired).toBe(true);
});
});

View File

@@ -0,0 +1,69 @@
import { PORT_MAX, PORT_MIN } from "../../constants/index.js";
import { BaseField } from "../../core/field/field.js";
import { CoercionError } from "../../errors/env-error.js";
/**
* A field builder for network port environment variables.
*
* Ensures the value is an integer within the valid port range
* (1–65535 by default). The range can be further constrained via
* `min()` and `max()`.
*/
export class PortField<TRequired extends boolean = true> extends BaseField<
number,
TRequired
> {
protected _min: number = PORT_MIN;
protected _max: number = PORT_MAX;
required(): PortField<true> {
this._markRequired();
return this as unknown as PortField<true>;
}
optional(): PortField<false> {
this._markOptional();
return this as unknown as PortField<false>;
}
default(value: number): PortField<true> {
this._setDefault(value);
return this as unknown as PortField<true>;
}
/** Set a custom minimum port value. */
min(n: number): this {
this._min = n;
return this;
}
/** Set a custom maximum port value. */
max(n: number): this {
this._max = n;
return this;
}
coerce(raw: string): number {
const trimmed = raw.trim();
const value = Number(trimmed);
if (Number.isNaN(value) || !Number.isInteger(value)) {
throw new CoercionError("", trimmed, "port number");
}
if (value < this._min || value > this._max) {
throw new CoercionError(
"",
trimmed,
`port number (${this._min}–${this._max})`
);
}
return value;
}
}
/** Create a port field builder. */
export function port(): PortField<true> {
return new PortField();
}

View File

@@ -0,0 +1 @@
export { StringField, str } from "./string-field";

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,88 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { StringField, str } from "./string-field.js";
describe("str / StringField", () => {
it("factory returns StringField instance", { timeout: 5000 }, () => {
expect(str()).toBeInstanceOf(StringField);
});
it("coerce returns raw string", { timeout: 5000 }, () => {
expect(str().coerce("hello")).toBe("hello");
});
it("trim() trims whitespace", { timeout: 5000 }, () => {
expect(str().trim().coerce(" hello ")).toBe("hello");
});
it("lowercase() lowercases value", { timeout: 5000 }, () => {
expect(str().lowercase().coerce("HELLO")).toBe("hello");
});
it("uppercase() uppercases value", { timeout: 5000 }, () => {
expect(str().uppercase().coerce("hello")).toBe("HELLO");
});
it("minLength() adds rule", { timeout: 5000 }, () => {
const f = str().minLength(3);
expect(f._rules.length).toBe(1);
expect(f._rules[0]!.validate("ab")).toBe(false);
expect(f._rules[0]!.validate("abc")).toBe(true);
});
it("maxLength() adds rule", { timeout: 5000 }, () => {
const f = str().maxLength(5);
expect(f._rules[0]!.validate("hello")).toBe(true);
expect(f._rules[0]!.validate("toolong")).toBe(false);
});
it("pattern() adds regex rule", { timeout: 5000 }, () => {
const f = str().pattern(/^\d+$/);
expect(f._rules[0]!.validate("123")).toBe(true);
expect(f._rules[0]!.validate("abc")).toBe(false);
});
it("pattern() uses custom message", { timeout: 5000 }, () => {
const f = str().pattern(/^\d+$/, "digits only");
expect(f._rules[0]!.message).toBe("digits only");
});
it("nonempty() adds rule", { timeout: 5000 }, () => {
const f = str().nonempty();
expect(f._rules[0]!.validate("")).toBe(false);
expect(f._rules[0]!.validate("x")).toBe(true);
});
it("startsWith() adds rule", { timeout: 5000 }, () => {
const f = str().startsWith("http");
expect(f._rules[0]!.validate("https://x")).toBe(true);
expect(f._rules[0]!.validate("ftp://x")).toBe(false);
});
it("endsWith() adds rule", { timeout: 5000 }, () => {
const f = str().endsWith(".js");
expect(f._rules[0]!.validate("file.js")).toBe(true);
expect(f._rules[0]!.validate("file.ts")).toBe(false);
});
it("includes() adds rule", { timeout: 5000 }, () => {
const f = str().includes("foo");
expect(f._rules[0]!.validate("foobar")).toBe(true);
expect(f._rules[0]!.validate("barbar")).toBe(false);
});
it("required() marks required", { timeout: 5000 }, () => {
const f = str().optional().required();
expect(f._isRequired).toBe(true);
});
it("optional() marks optional", { timeout: 5000 }, () => {
const f = str().optional();
expect(f._isRequired).toBe(false);
});
it("default() sets default", { timeout: 5000 }, () => {
const f = str().default("foo");
expect(f._hasDefault).toBe(true);
expect(f._defaultValue).toBe("foo");
});
});

View File

@@ -0,0 +1,132 @@
import { BaseField } from "../../core/field/field.js";
/**
* A field builder for string environment variables.
*
* Supports optional whitespace trimming, case transformation,
* length constraints, regex pattern matching, and substring checks.
*/
export class StringField<TRequired extends boolean = true> extends BaseField<
string,
TRequired
> {
protected _trimValue = false;
protected _lowercaseValue = false;
protected _uppercaseValue = false;
required(): StringField<true> {
this._markRequired();
return this as unknown as StringField<true>;
}
optional(): StringField<false> {
this._markOptional();
return this as unknown as StringField<false>;
}
default(value: string): StringField<true> {
this._setDefault(value);
return this as unknown as StringField<true>;
}
/** Trim whitespace from both ends before validation. */
trim(): this {
this._trimValue = true;
return this;
}
/** Convert to lowercase before validation. */
lowercase(): this {
this._lowercaseValue = true;
return this;
}
/** Convert to uppercase before validation. */
uppercase(): this {
this._uppercaseValue = true;
return this;
}
/** Require at least `n` characters. */
minLength(n: number): this {
this._rules.push({
name: "minLength",
message: `Must be at least ${n} characters`,
validate: (v) => v.length >= n,
});
return this;
}
/** Require at most `n` characters. */
maxLength(n: number): this {
this._rules.push({
name: "maxLength",
message: `Must be at most ${n} characters`,
validate: (v) => v.length <= n,
});
return this;
}
/** Require the value to match a regular expression. */
pattern(regex: RegExp, message?: string): this {
this._rules.push({
name: "pattern",
message: message ?? `Must match pattern ${regex}`,
validate: (v) => regex.test(v),
});
return this;
}
/** Require the value to not be empty (length > 0). */
nonempty(): this {
this._rules.push({
name: "nonempty",
message: "Must not be empty",
validate: (v) => v.length > 0,
});
return this;
}
/** Require the value to start with a specific prefix. */
startsWith(prefix: string): this {
this._rules.push({
name: "startsWith",
message: `Must start with "${prefix}"`,
validate: (v) => v.startsWith(prefix),
});
return this;
}
/** Require the value to end with a specific suffix. */
endsWith(suffix: string): this {
this._rules.push({
name: "endsWith",
message: `Must end with "${suffix}"`,
validate: (v) => v.endsWith(suffix),
});
return this;
}
/** Require the value to include a substring. */
includes(substring: string): this {
this._rules.push({
name: "includes",
message: `Must include "${substring}"`,
validate: (v) => v.includes(substring),
});
return this;
}
coerce(raw: string): string {
let value = raw;
if (this._trimValue) value = value.trim();
if (this._lowercaseValue) value = value.toLowerCase();
if (this._uppercaseValue) value = value.toUpperCase();
return value;
}
}
/** Create a string field builder. */
export function str(): StringField<true> {
return new StringField();
}

View File

@@ -0,0 +1 @@
export { UrlField, url } from "./url-field";

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,61 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CoercionError } from "../../errors/env-error.js";
import { UrlField, url } from "./url-field.js";
describe("url / UrlField", () => {
it("factory returns UrlField instance", { timeout: 5000 }, () => {
expect(url()).toBeInstanceOf(UrlField);
});
it("coerce accepts valid https URL", { timeout: 5000 }, () => {
const result = url().coerce("https://example.com");
expect(result).toContain("example.com");
});
it("coerce throws for invalid URL", { timeout: 5000 }, () => {
expect(() => url().coerce("not a url")).toThrow(CoercionError);
});
it("protocols() restricts protocol", { timeout: 5000 }, () => {
const f = url().protocols(["https"]);
expect(() => f.coerce("http://example.com")).toThrow(CoercionError);
expect(f.coerce("https://example.com")).toContain("example.com");
});
it("protocols() accepts protocol with trailing colon", {
timeout: 5000,
}, () => {
const f = url().protocols(["https:"]);
expect(f.coerce("https://example.com")).toContain("example.com");
});
it("noTrailingSlash() removes trailing slash", { timeout: 5000 }, () => {
const result = url()
.noTrailingSlash()
.coerce("https://example.com/path/");
expect(result.endsWith("/")).toBe(false);
});
it("noTrailingSlash() strips root trailing slash", {
timeout: 5000,
}, () => {
// https://example.com/ → https://example.com (slash removed)
const result = url().noTrailingSlash().coerce("https://example.com/");
expect(result).toBe("https://example.com");
});
it("optional() marks optional", { timeout: 5000 }, () => {
const f = url().optional();
expect(f._isRequired).toBe(false);
});
it("default() sets default", { timeout: 5000 }, () => {
const f = url().default("https://localhost");
expect(f._defaultValue).toBe("https://localhost");
});
it("required() marks required", { timeout: 5000 }, () => {
const f = url().optional().required();
expect(f._isRequired).toBe(true);
});
});

View File

@@ -0,0 +1,83 @@
import { BaseField } from "../../core/field/field.js";
import { CoercionError } from "../../errors/env-error.js";
/**
* A field builder for URL environment variables.
*
* Validates that the value is a well-formed URL using the native
* `URL` constructor. Supports protocol allowlists and optional
* trailing-slash removal.
*/
export class UrlField<TRequired extends boolean = true> extends BaseField<
string,
TRequired
> {
protected _allowedProtocols: string[] = [];
protected _removeTrailingSlash = false;
required(): UrlField<true> {
this._markRequired();
return this as unknown as UrlField<true>;
}
optional(): UrlField<false> {
this._markOptional();
return this as unknown as UrlField<false>;
}
default(value: string): UrlField<true> {
this._setDefault(value);
return this as unknown as UrlField<true>;
}
/** Restrict to specific URL protocols, e.g. `['https', 'http']`. */
protocols(protocols: string[]): this {
this._allowedProtocols = protocols.map((p) =>
p.endsWith(":") ? p : `${p}:`
);
return this;
}
/** Strip a trailing slash from the resolved URL string. */
noTrailingSlash(): this {
this._removeTrailingSlash = true;
return this;
}
coerce(raw: string): string {
const trimmed = raw.trim();
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
throw new CoercionError("", trimmed, "URL");
}
if (this._allowedProtocols.length > 0) {
const allowed = this._allowedProtocols.some(
(p) => parsed.protocol === p
);
if (!allowed) {
throw new CoercionError(
"",
trimmed,
`URL with protocol ${this._allowedProtocols.join(" or ")}`
);
}
}
let result = parsed.href;
if (this._removeTrailingSlash && result.endsWith("/")) {
// Always safe to remove trailing slash from a well-formed URL
// since the protocol separator (://) retains the slash after the host.
result = result.slice(0, -1);
}
return result;
}
}
/** Create a URL field builder. */
export function url(): UrlField<true> {
return new UrlField();
}

View File

@@ -0,0 +1,128 @@
// Constants
export {
DEFAULT_ARRAY_SEPARATOR,
EMAIL_PATTERN,
FALSY_VALUES,
INTERPOLATION_PATTERN,
PORT_MAX,
PORT_MIN,
TRUTHY_VALUES,
} from "./constants/index.js";
// Core
export { createEnv } from "./core/create-env/create-env.js";
export type {
CollectedErrors,
EnvResult,
} from "./core/create-env/create-env.types.js";
export { BaseField } from "./core/field/field.js";
export type { FieldConfig, RuleOptions } from "./core/field/field.types.js";
export { maskSensitive, validate } from "./core/validator/validator.js";
export type {
ValidateInput,
ValidationResult,
} from "./core/validator/validator.types.js";
// Errors
export {
CoercionError,
createEnvError,
DotenvParseError,
EnvBaseError,
EnvValidationError,
MissingEnvError,
} from "./errors/env-error.js";
export { ENV_CODES, type EnvCode } from "./errors/error-codes.js";
export { ArrayField, array } from "./fields/array-field/array-field.js";
export type { ItemField } from "./fields/array-field/array-field.types.js";
export {
BooleanField,
bool,
} from "./fields/boolean-field/boolean-field.js";
export { EmailField, email } from "./fields/email-field/email-field.js";
export { EnumField, enums } from "./fields/enum-field/enum-field.js";
export type { EnumValues } from "./fields/enum-field/enum-field.types.js";
export { JsonField, json } from "./fields/json-field/json-field.js";
export type { JsonValidator } from "./fields/json-field/json-field.types.js";
export { NumberField, num } from "./fields/number-field/number-field.js";
export { PortField, port } from "./fields/port-field/port-field.js";
// Fields
export { StringField, str } from "./fields/string-field/string-field.js";
export { UrlField, url } from "./fields/url-field/url-field.js";
export type { DotenvLoaderOptions } from "./loaders/dotenv/dotenv.js";
// Loaders
export {
dotenvLoader,
parseDotenv,
} from "./loaders/dotenv/dotenv.js";
export type { ObjectLoaderOptions } from "./loaders/object/object.js";
export { objectLoader } from "./loaders/object/object.js";
// Presets — common
export {
appNameField,
appVersionField,
commonFields,
debugField,
hostField,
logLevelField,
nodeEnvField,
portField,
timezoneField,
workersField,
} from "./presets/common/common.js";
// Presets — server
export {
corsOriginField,
databaseUrlField,
rateLimitMaxField,
rateLimitWindowField,
redisUrlField,
serverFields,
sessionSecretField,
smtpFromField,
smtpHostField,
smtpPassField,
smtpPortField,
smtpUserField,
trustProxyField,
} from "./presets/server/server.js";
// Transforms
export {
coerceArray,
coerceBoolean,
coerceInteger,
coerceJson,
coerceNumber,
coerceString,
} from "./transforms/coerce/coerce.js";
export type {
CoerceEntry,
CoerceFn,
} from "./transforms/coerce/coerce.types.js";
// Types
export type {
AnyField,
CreateEnvOptions,
EnvError,
EnvSchema,
ErrorMode,
InferEnv,
InferFieldType,
Loader,
LoaderResult,
OptionalKeys,
RequiredKeys,
Simplify,
ValidationRule,
} from "./types/common.js";
// Utilities
export {
extractVariables,
hasInterpolation,
interpolate,
} from "./utils/interpolation.js";

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,181 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { dotenvLoader, parseDotenv } from "./dotenv.js";
describe("parseDotenv", () => {
it("parses simple key=value", { timeout: 5000 }, () => {
expect(parseDotenv("KEY=value")).toEqual({ KEY: "value" });
});
it("skips blank lines", { timeout: 5000 }, () => {
expect(parseDotenv("\nKEY=value\n")).toEqual({ KEY: "value" });
});
it("skips comment lines", { timeout: 5000 }, () => {
expect(parseDotenv("# comment\nKEY=value")).toEqual({ KEY: "value" });
});
it("strips export prefix", { timeout: 5000 }, () => {
expect(parseDotenv("export KEY=value")).toEqual({ KEY: "value" });
});
it("parses double-quoted values with escape sequences", {
timeout: 5000,
}, () => {
const result = parseDotenv('KEY="hello\\nworld"');
expect(result.KEY).toBe("hello\nworld");
});
it("parses double-quoted values with tab escape", { timeout: 5000 }, () => {
const result = parseDotenv('KEY="a\\tb"');
expect(result.KEY).toBe("a\tb");
});
it("parses double-quoted values with quote escape", {
timeout: 5000,
}, () => {
const result = parseDotenv('KEY="say \\"hi\\""');
expect(result.KEY).toBe('say "hi"');
});
it("parses double-quoted values with backslash escape", {
timeout: 5000,
}, () => {
const result = parseDotenv('KEY="a\\\\b"');
expect(result.KEY).toBe("a\\b");
});
it("parses single-quoted values literally", { timeout: 5000 }, () => {
const result = parseDotenv("KEY='hello\\nworld'");
expect(result.KEY).toBe("hello\\nworld");
});
it("parses empty value", { timeout: 5000 }, () => {
expect(parseDotenv("KEY=")).toEqual({ KEY: "" });
});
it("parses value with = in it", { timeout: 5000 }, () => {
expect(parseDotenv("KEY=a=b=c")).toEqual({ KEY: "a=b=c" });
});
it("strips inline comment from unquoted value", { timeout: 5000 }, () => {
expect(parseDotenv("KEY=value # comment")).toEqual({ KEY: "value" });
});
it("handles CRLF line endings", { timeout: 5000 }, () => {
expect(parseDotenv("A=1\r\nB=2")).toEqual({ A: "1", B: "2" });
});
it("skips lines without = sign", { timeout: 5000 }, () => {
expect(parseDotenv("NOEQUALS\nKEY=value")).toEqual({ KEY: "value" });
});
it("interpolates ${VAR} tokens", { timeout: 5000 }, () => {
const result = parseDotenv("BASE=hello\nGREET=${BASE}_world");
expect(result.GREET).toBe("hello_world");
});
it("interpolates $VAR tokens", { timeout: 5000 }, () => {
const result = parseDotenv("NAME=alice\nGREET=hello_$NAME");
expect(result.GREET).toBe("hello_alice");
});
it("multiple keys", { timeout: 5000 }, () => {
const result = parseDotenv("A=1\nB=2\nC=3");
expect(result).toEqual({ A: "1", B: "2", C: "3" });
});
it("skips lines with empty key (=value with no key)", {
timeout: 5000,
}, () => {
// "=value" has no key, should be skipped
const result = parseDotenv("=orphan\nKEY=value");
expect(result).toEqual({ KEY: "value" });
expect("" in result).toBe(false);
});
});
describe("dotenvLoader", () => {
it("reads file and parses it", { timeout: 5000 }, () => {
const dir = mkdtempSync(join(tmpdir(), "env-test-"));
const filePath = join(dir, ".env");
writeFileSync(filePath, "PORT=4000\nHOST=localhost");
const loader = dotenvLoader({ path: filePath });
const result = loader.load();
expect(result.values.PORT).toBe("4000");
expect(result.values.HOST).toBe("localhost");
expect(result.source).toBe(filePath);
expect(loader.name).toBe(filePath);
rmSync(dir, { recursive: true });
});
it("returns empty values when file does not exist", {
timeout: 5000,
}, () => {
const loader = dotenvLoader({ path: "/nonexistent/.env" });
const result = loader.load();
expect(result.values).toEqual({});
expect(result.source).toBe("/nonexistent/.env");
});
it("defaults path to .env", { timeout: 5000 }, () => {
const loader = dotenvLoader();
expect(loader.name).toBe(".env");
});
it("uses custom encoding", { timeout: 5000 }, () => {
const dir = mkdtempSync(join(tmpdir(), "env-test-"));
const filePath = join(dir, ".env");
writeFileSync(filePath, "KEY=value", "utf8");
const loader = dotenvLoader({ path: filePath, encoding: "utf8" });
const result = loader.load();
expect(result.values.KEY).toBe("value");
rmSync(dir, { recursive: true });
});
});
describe("parseDotenv — property-based tests", () => {
it("same input always yields same output (purity)", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.array(
fc.record({
key: fc.stringMatching(/^[A-Z][A-Z0-9_]{0,10}$/),
value: fc
.string({ maxLength: 20 })
.filter(
(v) =>
!v.includes("\n") &&
!v.includes("\r") &&
!v.includes('"') &&
!v.includes("'") &&
!v.includes("\\") &&
!v.startsWith("#") &&
!v.includes("${") &&
!v.includes("$")
),
}),
{ maxLength: 5 }
),
(pairs) => {
const content = pairs
.map(({ key, value }) => `${key}=${value}`)
.join("\n");
const r1 = parseDotenv(content);
const r2 = parseDotenv(content);
expect(r1).toEqual(r2);
}
),
{ numRuns: 100 }
);
});
});

View File

@@ -0,0 +1,142 @@
// NOTE: This file intentionally uses node:fs for reading .env files from disk.
// The dotenvLoader function is approved for node:fs usage — it is a Node.js
// loader that requires file I/O. The parseDotenv function is pure (no I/O).
import { readFileSync } from "node:fs";
import { EnvValidationError } from "../../errors/env-error.js";
import { DotenvLoaderOptionsSchema } from "../../schemas/env.schemas.js";
import type { Loader, LoaderResult } from "../../types/common.js";
import { hasInterpolation, interpolate } from "../../utils/interpolation.js";
/**
* Parse a dotenv-formatted string into key-value pairs.
*
* Supports:
* - `KEY=value` (unquoted)
* - `KEY="value"` (double-quoted, with escape sequences `\n`, `\r`, `\t`, `\"`, `\\`)
* - `KEY='value'` (single-quoted, literal)
* - `KEY=` (empty value)
* - Comments (`# ...`) and blank lines (skipped)
* - `export KEY=value` prefix (stripped)
* - Variable interpolation in unquoted/double-quoted values (`${VAR}` / `$VAR`)
*
* This is a pure function with no I/O. Use `dotenvLoader` for file reading.
*
* @param content - The raw dotenv file content string.
* @returns A record of parsed key-value pairs.
*/
export function parseDotenv(content: string): Record<string, string> {
const result: Record<string, string> = {};
const lines = content.split(/\r?\n/);
for (const rawLine of lines) {
const line = rawLine.trim();
if (line === "" || line.startsWith("#")) continue;
const withoutExport = line.startsWith("export ")
? line.slice(7).trim()
: line;
const eqIndex = withoutExport.indexOf("=");
if (eqIndex === -1) continue;
const key = withoutExport.slice(0, eqIndex).trim();
if (!key) continue;
let value = withoutExport.slice(eqIndex + 1).trim();
value = parseRawValue(value);
if (hasInterpolation(value)) {
value = interpolate(value, result);
}
result[key] = value;
}
return result;
}
function parseRawValue(raw: string): string {
if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) {
return raw
.slice(1, -1)
.replace(/\\n/g, "\n")
.replace(/\\r/g, "\r")
.replace(/\\t/g, "\t")
.replace(/\\"/g, '"')
.replace(/\\\\/g, "\\");
}
if (raw.startsWith("'") && raw.endsWith("'") && raw.length >= 2) {
return raw.slice(1, -1);
}
const commentIndex = raw.indexOf(" #");
if (commentIndex !== -1) {
return raw.slice(0, commentIndex).trim();
}
return raw;
}
/** Options for the dotenv file loader. */
export interface DotenvLoaderOptions {
/** Path to the dotenv file. Defaults to `.env`. */
readonly path?: string;
/** File encoding. Defaults to `'utf8'`. */
readonly encoding?: BufferEncoding;
/**
* When `true`, loader values override any values already present
* in the merged environment. Defaults to `false`.
*/
readonly override?: boolean;
}
/**
* Create a loader that reads a dotenv file from disk and parses it.
*
* This loader uses Node.js `fs.readFileSync`. If the file does not
* exist the loader returns an empty values map (graceful fallback).
*
* NOTE: `node:fs` usage here is intentional and approved. This is
* a Node.js-specific loader; universal code should use `objectLoader`.
*
* @example
* ```ts
* const loader = dotenvLoader({ path: '.env.local' });
* const env = createEnv({ schema, loaders: [loader], source: process.env });
* ```
*/
export function dotenvLoader(options: DotenvLoaderOptions = {}): Loader {
const parsed = DotenvLoaderOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new EnvValidationError(
parsed.error.issues.map((i) => ({
key:
i.path.length > 0
? i.path.map(String).join(".")
: "options",
message: i.message,
}))
);
}
const filePath = options.path ?? ".env";
const encoding = options.encoding ?? "utf8";
return {
name: filePath,
load(): LoaderResult {
let content: string;
try {
content = readFileSync(filePath, { encoding });
} catch {
// File not found — return empty gracefully
return { values: {}, source: filePath };
}
return {
values: parseDotenv(content),
source: filePath,
};
},
};
}

View File

@@ -0,0 +1 @@
export { type DotenvLoaderOptions, dotenvLoader, parseDotenv } from "./dotenv";

View File

@@ -0,0 +1,2 @@
export { type DotenvLoaderOptions, dotenvLoader, parseDotenv } from "./dotenv";
export { type ObjectLoaderOptions, objectLoader } from "./object";

View File

@@ -0,0 +1 @@
export { type ObjectLoaderOptions, objectLoader } from "./object";

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,53 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { objectLoader } from "./object.js";
describe("objectLoader", () => {
it("wraps a plain object", { timeout: 5000 }, () => {
const loader = objectLoader({
source: { PORT: "3000", HOST: "localhost" },
});
const result = loader.load();
expect(result.values.PORT).toBe("3000");
expect(result.values.HOST).toBe("localhost");
});
it("filters out undefined values", { timeout: 5000 }, () => {
const loader = objectLoader({
source: { PORT: "3000", MISSING: undefined },
});
const result = loader.load();
expect(result.values.PORT).toBe("3000");
expect("MISSING" in result.values).toBe(false);
});
it("uses sourceName as loader name and source label", {
timeout: 5000,
}, () => {
const loader = objectLoader({
source: {},
sourceName: "process.env",
});
expect(loader.name).toBe("process.env");
expect(loader.load().source).toBe("process.env");
});
it("defaults name and source to 'object'", { timeout: 5000 }, () => {
const loader = objectLoader({ source: {} });
expect(loader.name).toBe("object");
expect(loader.load().source).toBe("object");
});
it("handles empty source", { timeout: 5000 }, () => {
const loader = objectLoader({ source: {} });
expect(loader.load().values).toEqual({});
});
it("returns new values record each call", { timeout: 5000 }, () => {
const source = { A: "1" };
const loader = objectLoader({ source });
const r1 = loader.load();
const r2 = loader.load();
expect(r1.values).not.toBe(r2.values);
expect(r1.values).toEqual(r2.values);
});
});

View File

@@ -0,0 +1,56 @@
import { EnvValidationError } from "../../errors/env-error.js";
import { ObjectLoaderOptionsSchema } from "../../schemas/env.schemas.js";
import type { Loader, LoaderResult } from "../../types/common.js";
/** Options for the object loader. */
export interface ObjectLoaderOptions {
/** The object to load values from (e.g. `process.env`). */
readonly source: Record<string, string | undefined>;
/** Human-readable source name used in diagnostics. */
readonly sourceName?: string;
}
/**
* Create a loader that reads from a plain in-memory object.
*
* Filters out entries whose value is `undefined`, converting the
* rest into a clean `Record<string, string>`. This is the most
* common loader, typically wrapping `process.env`.
*
* @example
* ```ts
* const loader = objectLoader({ source: process.env, sourceName: 'process.env' });
* const { values } = loader.load();
* ```
*/
export function objectLoader(options: ObjectLoaderOptions): Loader {
const parsed = ObjectLoaderOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new EnvValidationError(
parsed.error.issues.map((i) => ({
key:
i.path.length > 0
? i.path.map(String).join(".")
: "options",
message: i.message,
}))
);
}
return {
name: options.sourceName ?? "object",
load(): LoaderResult {
const values: Record<string, string> = {};
for (const [key, val] of Object.entries(options.source)) {
if (val !== undefined) {
values[key] = val;
}
}
return {
values,
source: options.sourceName ?? "object",
};
},
};
}

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,111 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createEnv } from "../../core/create-env/create-env.js";
import {
appNameField,
appVersionField,
commonFields,
debugField,
hostField,
logLevelField,
nodeEnvField,
portField,
timezoneField,
workersField,
} from "./common.js";
describe("common presets", () => {
it("nodeEnvField defaults to development", { timeout: 5000 }, () => {
const env = createEnv({
schema: { NODE_ENV: nodeEnvField() },
source: {},
});
expect(env.NODE_ENV).toBe("development");
});
it("nodeEnvField accepts all allowed values", { timeout: 5000 }, () => {
for (const v of ["development", "production", "test", "staging"]) {
const env = createEnv({
schema: { NODE_ENV: nodeEnvField() },
source: { NODE_ENV: v },
});
expect(env.NODE_ENV).toBe(v);
}
});
it("portField defaults to 3000", { timeout: 5000 }, () => {
const env = createEnv({ schema: { PORT: portField() }, source: {} });
expect(env.PORT).toBe(3000);
});
it("portField parses custom port", { timeout: 5000 }, () => {
const env = createEnv({
schema: { PORT: portField() },
source: { PORT: "8080" },
});
expect(env.PORT).toBe(8080);
});
it("hostField defaults to 0.0.0.0", { timeout: 5000 }, () => {
const env = createEnv({ schema: { HOST: hostField() }, source: {} });
expect(env.HOST).toBe("0.0.0.0");
});
it("debugField defaults to false", { timeout: 5000 }, () => {
const env = createEnv({ schema: { DEBUG: debugField() }, source: {} });
expect(env.DEBUG).toBe(false);
});
it("logLevelField defaults to info", { timeout: 5000 }, () => {
const env = createEnv({
schema: { LOG_LEVEL: logLevelField() },
source: {},
});
expect(env.LOG_LEVEL).toBe("info");
});
it("timezoneField defaults to UTC", { timeout: 5000 }, () => {
const env = createEnv({ schema: { TZ: timezoneField() }, source: {} });
expect(env.TZ).toBe("UTC");
});
it("appNameField requires a value", { timeout: 5000 }, () => {
const env = createEnv({
schema: { APP_NAME: appNameField() },
source: { APP_NAME: "myapp" },
});
expect(env.APP_NAME).toBe("myapp");
});
it("appVersionField defaults to 0.0.0", { timeout: 5000 }, () => {
const env = createEnv({
schema: { APP_VERSION: appVersionField() },
source: {},
});
expect(env.APP_VERSION).toBe("0.1.0");
});
it("workersField defaults to 1", { timeout: 5000 }, () => {
const env = createEnv({
schema: { WORKERS: workersField() },
source: {},
});
expect(env.WORKERS).toBe(1);
});
it("commonFields composite has expected keys", { timeout: 5000 }, () => {
expect(Object.keys(commonFields)).toContain("NODE_ENV");
expect(Object.keys(commonFields)).toContain("PORT");
expect(Object.keys(commonFields)).toContain("HOST");
expect(Object.keys(commonFields)).toContain("DEBUG");
expect(Object.keys(commonFields)).toContain("LOG_LEVEL");
});
it("commonFields composite resolves with defaults", {
timeout: 5000,
}, () => {
const env = createEnv({ schema: { ...commonFields }, source: {} });
expect(env.PORT).toBe(3000);
expect(env.HOST).toBe("0.0.0.0");
expect(env.DEBUG).toBe(false);
});
});

View File

@@ -0,0 +1,79 @@
import { bool } from "../../fields/boolean-field/boolean-field.js";
import { enums } from "../../fields/enum-field/enum-field.js";
import { num } from "../../fields/number-field/number-field.js";
import { port } from "../../fields/port-field/port-field.js";
import { str } from "../../fields/string-field/string-field.js";
/**
* Pre-built field definitions for common server environment variables.
*
* Import individual factory functions or the `commonFields` composite
* bundle to avoid repeating standard field configurations.
*
* @example
* ```ts
* import { commonFields } from '@suaveplan/env';
* import { createEnv, str } from '@suaveplan/env';
*
* const env = createEnv({
* schema: { ...commonFields, CUSTOM: str() },
* source: process.env,
* });
* ```
*/
/** NODE_ENV field: `'development' | 'production' | 'test' | 'staging'`. */
export const nodeEnvField = () =>
enums(["development", "production", "test", "staging"] as const).default(
"development"
);
/** PORT field: integer 1–65535, default 3000. */
export const portField = () => port().default(3000);
/** HOST field: string, default `'0.0.0.0'`. */
export const hostField = () => str().default("0.0.0.0").describe("Server host");
/** DEBUG field: boolean, default `false`. */
export const debugField = () =>
bool().default(false).describe("Enable debug mode");
/** LOG_LEVEL field: standard logger levels, default `'info'`. */
export const logLevelField = () =>
enums([
"trace",
"debug",
"info",
"warn",
"error",
"fatal",
] as const).default("info");
/** TZ field: IANA timezone string, default `'UTC'`. */
export const timezoneField = () =>
str().default("UTC").describe("IANA timezone");
/** APP_NAME field: required identifier string. */
export const appNameField = () =>
str().minLength(1).maxLength(64).describe("Application name");
/** APP_VERSION field: semver-like version string, default `'0.0.0'`. */
export const appVersionField = () =>
str().default("0.1.0").describe("Application version");
/** WORKERS field: positive integer worker count, default 1. */
export const workersField = () =>
num()
.integer()
.positive()
.default(1)
.describe("Number of worker processes");
/** Composite bundle of the five most common server fields. */
export const commonFields = {
NODE_ENV: nodeEnvField(),
PORT: portField(),
HOST: hostField(),
DEBUG: debugField(),
LOG_LEVEL: logLevelField(),
} as const;

View File

@@ -0,0 +1,12 @@
export {
appNameField,
appVersionField,
commonFields,
debugField,
hostField,
logLevelField,
nodeEnvField,
portField,
timezoneField,
workersField,
} from "./common";

View File

@@ -0,0 +1,27 @@
export {
appNameField,
appVersionField,
commonFields,
debugField,
hostField,
logLevelField,
nodeEnvField,
portField,
timezoneField,
workersField,
} from "./common";
export {
corsOriginField,
databaseUrlField,
rateLimitMaxField,
rateLimitWindowField,
redisUrlField,
serverFields,
sessionSecretField,
smtpFromField,
smtpHostField,
smtpPassField,
smtpPortField,
smtpUserField,
trustProxyField,
} from "./server";

View File

@@ -0,0 +1,15 @@
export {
corsOriginField,
databaseUrlField,
rateLimitMaxField,
rateLimitWindowField,
redisUrlField,
serverFields,
sessionSecretField,
smtpFromField,
smtpHostField,
smtpPassField,
smtpPortField,
smtpUserField,
trustProxyField,
} from "./server";

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,119 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createEnv } from "../../core/create-env/create-env.js";
import {
corsOriginField,
databaseUrlField,
rateLimitMaxField,
rateLimitWindowField,
redisUrlField,
serverFields,
sessionSecretField,
smtpFromField,
smtpHostField,
smtpPassField,
smtpPortField,
smtpUserField,
trustProxyField,
} from "./server.js";
describe("server presets", () => {
it("databaseUrlField is sensitive", { timeout: 5000 }, () => {
const f = databaseUrlField();
expect(f._isSensitive).toBe(true);
});
it("databaseUrlField requires valid URL with allowed protocol", {
timeout: 5000,
}, () => {
const env = createEnv({
schema: { DATABASE_URL: databaseUrlField() },
source: { DATABASE_URL: "postgres://localhost/db" },
});
expect(env.DATABASE_URL).toContain("localhost");
});
it("redisUrlField is sensitive", { timeout: 5000 }, () => {
expect(redisUrlField()._isSensitive).toBe(true);
});
it("sessionSecretField is sensitive and requires minLength 32", {
timeout: 5000,
}, () => {
const f = sessionSecretField();
expect(f._isSensitive).toBe(true);
expect(f._rules.length).toBeGreaterThan(0);
});
it("corsOriginField defaults to *", { timeout: 5000 }, () => {
const env = createEnv({
schema: { CORS_ORIGIN: corsOriginField() },
source: {},
});
expect(env.CORS_ORIGIN).toBe("*");
});
it("smtpHostField is a required string", { timeout: 5000 }, () => {
const env = createEnv({
schema: { SMTP_HOST: smtpHostField() },
source: { SMTP_HOST: "smtp.example.com" },
});
expect(env.SMTP_HOST).toBe("smtp.example.com");
});
it("smtpPortField defaults to 587", { timeout: 5000 }, () => {
const env = createEnv({
schema: { SMTP_PORT: smtpPortField() },
source: {},
});
expect(env.SMTP_PORT).toBe(587);
});
it("smtpUserField is sensitive", { timeout: 5000 }, () => {
expect(smtpUserField()._isSensitive).toBe(true);
});
it("smtpPassField is sensitive", { timeout: 5000 }, () => {
expect(smtpPassField()._isSensitive).toBe(true);
});
it("smtpFromField validates email", { timeout: 5000 }, () => {
const env = createEnv({
schema: { SMTP_FROM: smtpFromField() },
source: { SMTP_FROM: "noreply@example.com" },
});
expect(env.SMTP_FROM).toBe("noreply@example.com");
});
it("rateLimitMaxField defaults to 100", { timeout: 5000 }, () => {
const env = createEnv({
schema: { RATE_LIMIT_MAX: rateLimitMaxField() },
source: {},
});
expect(env.RATE_LIMIT_MAX).toBe(100);
});
it("rateLimitWindowField defaults to 60", { timeout: 5000 }, () => {
const env = createEnv({
schema: { RATE_LIMIT_WINDOW: rateLimitWindowField() },
source: {},
});
expect(env.RATE_LIMIT_WINDOW).toBe(60);
});
it("trustProxyField defaults to false", { timeout: 5000 }, () => {
const env = createEnv({
schema: { TRUST_PROXY: trustProxyField() },
source: {},
});
expect(env.TRUST_PROXY).toBe(false);
});
it("serverFields composite has expected keys", { timeout: 5000 }, () => {
const keys = Object.keys(serverFields);
expect(keys).toContain("DATABASE_URL");
expect(keys).toContain("CORS_ORIGIN");
expect(keys).toContain("TRUST_PROXY");
expect(keys).toContain("RATE_LIMIT_MAX");
expect(keys).toContain("RATE_LIMIT_WINDOW");
});
});

View File

@@ -0,0 +1,77 @@
import { bool } from "../../fields/boolean-field/boolean-field.js";
import { email } from "../../fields/email-field/email-field.js";
import { num } from "../../fields/number-field/number-field.js";
import { str } from "../../fields/string-field/string-field.js";
import { url } from "../../fields/url-field/url-field.js";
/**
* Pre-built field definitions for server-side environment variables.
*
* Covers database, Redis, SMTP, CORS, session, and rate-limit config.
*
* @example
* ```ts
* import { serverFields, databaseUrlField } from '@suaveplan/env';
* import { createEnv } from '@suaveplan/env';
*
* const env = createEnv({ schema: serverFields, source: process.env });
* ```
*/
/** DATABASE_URL: required connection URL for the primary database. */
export const databaseUrlField = () =>
url()
.protocols(["postgres", "postgresql", "mysql", "mongodb"])
.sensitive()
.describe("Primary database connection URL");
/** REDIS_URL: optional Redis connection URL. */
export const redisUrlField = () =>
url()
.protocols(["redis", "rediss"])
.sensitive()
.describe("Redis connection URL");
/** SESSION_SECRET: required sensitive string for cookie/session signing. */
export const sessionSecretField = () =>
str().minLength(32).sensitive().describe("Session signing secret");
/** CORS_ORIGIN: allowed origin URL or wildcard. Default `'*'`. */
export const corsOriginField = () =>
str().default("*").describe("CORS allowed origin");
/** SMTP_HOST: mail server hostname. */
export const smtpHostField = () => str().describe("SMTP server host");
/** SMTP_PORT: mail server port number. Default 587. */
export const smtpPortField = () =>
num().integer().min(1).max(65_535).default(587);
/** SMTP_USER: mail server authentication username. */
export const smtpUserField = () => str().sensitive().describe("SMTP username");
/** SMTP_PASS: mail server authentication password. */
export const smtpPassField = () => str().sensitive().describe("SMTP password");
/** SMTP_FROM: default sender address for outgoing mail. */
export const smtpFromField = () => email().describe("Default sender email");
/** RATE_LIMIT_MAX: maximum number of requests per window. Default 100. */
export const rateLimitMaxField = () => num().integer().positive().default(100);
/** RATE_LIMIT_WINDOW_MS: window duration in milliseconds. Default 60. */
export const rateLimitWindowField = () =>
num().integer().positive().default(60);
/** TRUST_PROXY: trust `X-Forwarded-*` headers. Default `false`. */
export const trustProxyField = () =>
bool().default(false).describe("Trust X-Forwarded-* headers");
/** Composite bundle of core server connection fields. */
export const serverFields = {
DATABASE_URL: databaseUrlField(),
CORS_ORIGIN: corsOriginField(),
TRUST_PROXY: trustProxyField(),
RATE_LIMIT_MAX: rateLimitMaxField(),
RATE_LIMIT_WINDOW: rateLimitWindowField(),
} as const;

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,84 @@
import z from "zod";
/**
* Zod schema for a single validation rule.
*/
export const ValidationRuleSchema = z.object({
name: z.string(),
message: z.string(),
});
/**
* Zod schema validating the FieldConfig<unknown> shape at runtime.
*/
export const FieldConfigSchema = z.object({
isRequired: z.boolean(),
hasDefault: z.boolean(),
defaultValue: z.unknown().optional(),
isSensitive: z.boolean(),
description: z.string().optional(),
rules: z.array(ValidationRuleSchema),
});
/**
* Zod schema validating a single env error value object.
*/
export const EnvErrorValueSchema = z.object({
key: z.string(),
message: z.string(),
value: z.string().optional(),
});
/**
* Zod schema validating the ValidationResult produced by validate().
*/
export const ValidationResultSchema = z.object({
success: z.boolean(),
data: z.record(z.string(), z.unknown()),
errors: z.array(EnvErrorValueSchema),
});
/**
* Zod schema for the EnvResult (merged output from createEnv).
*/
export const EnvResultSchema = z.record(z.string(), z.unknown());
/**
* Zod schema for CreateEnvOptions. The `schema` field carries field
* constructors (runtime objects), so its value is left as `unknown` —
* we only assert that the entry exists and is an object.
*/
export const CreateEnvOptionsSchema = z
.object({
schema: z.record(z.string(), z.unknown()),
source: z.record(z.string(), z.string().optional()).optional(),
loaders: z.array(z.unknown()).optional(),
prefix: z.string().optional(),
onError: z.enum(["throw", "collect", "warn"]).optional(),
})
.passthrough();
/**
* Zod schema for the file-based `dotenvLoader()` options.
*/
export const DotenvLoaderOptionsSchema = z.object({
path: z.string().optional(),
encoding: z.string().optional(),
override: z.boolean().optional(),
});
/**
* Zod schema for the in-memory `parseDotenv()` helper input.
*/
export const ParseDotenvOptionsSchema = z.object({
content: z.string(),
sourceName: z.string().optional(),
});
/**
* Zod schema for ObjectLoaderOptions.
*/
export const ObjectLoaderOptionsSchema = z.object({
source: z.record(z.string(), z.string().optional()),
sourceName: z.string().optional(),
});

View File

@@ -0,0 +1,11 @@
export {
CreateEnvOptionsSchema,
DotenvLoaderOptionsSchema,
EnvErrorValueSchema,
EnvResultSchema,
FieldConfigSchema,
ObjectLoaderOptionsSchema,
ParseDotenvOptionsSchema,
ValidationResultSchema,
ValidationRuleSchema,
} from "./env.schemas";

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,171 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { FALSY_VALUES, TRUTHY_VALUES } from "../../constants/index.js";
import { CoercionError } from "../../errors/env-error.js";
import {
coerceArray,
coerceBoolean,
coerceInteger,
coerceJson,
coerceNumber,
coerceString,
} from "./coerce.js";
describe("coerceString", () => {
it("trims whitespace", { timeout: 5000 }, () => {
expect(coerceString(" hello ")).toBe("hello");
});
it("identity for clean string", { timeout: 5000 }, () => {
expect(coerceString("world")).toBe("world");
});
});
describe("coerceNumber", () => {
it("parses integer", { timeout: 5000 }, () => {
expect(coerceNumber("42")).toBe(42);
});
it("parses float", { timeout: 5000 }, () => {
expect(coerceNumber("3.14")).toBeCloseTo(3.14);
});
it("throws for empty string", { timeout: 5000 }, () => {
expect(() => coerceNumber("")).toThrow(CoercionError);
});
it("throws for NaN input", { timeout: 5000 }, () => {
expect(() => coerceNumber("abc")).toThrow(CoercionError);
});
it("throws for whitespace-only", { timeout: 5000 }, () => {
expect(() => coerceNumber(" ")).toThrow(CoercionError);
});
});
describe("coerceInteger", () => {
it("parses integer", { timeout: 5000 }, () => {
expect(coerceInteger("42")).toBe(42);
expect(coerceInteger("-7")).toBe(-7);
});
it("throws for float", { timeout: 5000 }, () => {
expect(() => coerceInteger("3.14")).toThrow(CoercionError);
});
it("throws for non-numeric", { timeout: 5000 }, () => {
expect(() => coerceInteger("abc")).toThrow(CoercionError);
});
});
describe("coerceBoolean", () => {
it("coerces all TRUTHY_VALUES", { timeout: 5000 }, () => {
for (const v of TRUTHY_VALUES) {
expect(coerceBoolean(v)).toBe(true);
}
});
it("coerces all FALSY_VALUES", { timeout: 5000 }, () => {
for (const v of FALSY_VALUES) {
expect(coerceBoolean(v)).toBe(false);
}
});
it("case-insensitive", { timeout: 5000 }, () => {
expect(coerceBoolean("TRUE")).toBe(true);
expect(coerceBoolean("FALSE")).toBe(false);
});
it("throws for unknown value", { timeout: 5000 }, () => {
expect(() => coerceBoolean("maybe")).toThrow(CoercionError);
});
});
describe("coerceJson", () => {
it("parses object", { timeout: 5000 }, () => {
expect(coerceJson('{"a":1}')).toEqual({ a: 1 });
});
it("parses array", { timeout: 5000 }, () => {
expect(coerceJson("[1,2]")).toEqual([1, 2]);
});
it("parses null", { timeout: 5000 }, () => {
expect(coerceJson("null")).toBeNull();
});
it("throws for invalid JSON", { timeout: 5000 }, () => {
expect(() => coerceJson("{bad}")).toThrow(CoercionError);
});
});
describe("coerceArray", () => {
it("splits on comma by default", { timeout: 5000 }, () => {
expect(coerceArray(coerceString)("a,b,c")).toEqual(["a", "b", "c"]);
});
it("uses custom separator", { timeout: 5000 }, () => {
expect(coerceArray(coerceString, "|")("a|b")).toEqual(["a", "b"]);
});
it("trims items", { timeout: 5000 }, () => {
expect(coerceArray(coerceString)(" a , b ")).toEqual(["a", "b"]);
});
it("returns empty array for whitespace-only input", {
timeout: 5000,
}, () => {
expect(coerceArray(coerceString)(" ")).toEqual([]);
});
it("filters empty parts", { timeout: 5000 }, () => {
expect(coerceArray(coerceString)("a,,b")).toEqual(["a", "b"]);
});
it("applies item coerce function", { timeout: 5000 }, () => {
expect(coerceArray(coerceNumber)("1,2,3")).toEqual([1, 2, 3]);
});
});
describe("coerce — property-based tests", () => {
it("coerceNumber round-trips for finite floats", { timeout: 30000 }, () => {
fc.assert(
fc.property(
fc.float({ noDefaultInfinity: true, noNaN: true }),
(n) => {
const raw = String(n);
const result = coerceNumber(raw);
expect(typeof result).toBe("number");
expect(Number.isNaN(result)).toBe(false);
}
),
{ numRuns: 200 }
);
});
it("coerceBoolean is total over TRUTHY_VALUES ∪ FALSY_VALUES", {
timeout: 30000,
}, () => {
const all = [...TRUTHY_VALUES, ...FALSY_VALUES];
for (const v of all) {
expect(() => coerceBoolean(v)).not.toThrow();
}
});
it("coerceArray: same input always yields same output", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.array(fc.string({ maxLength: 20 }), { maxLength: 10 }),
(parts) => {
const raw = parts.join(",");
const r1 = coerceArray(coerceString)(raw);
const r2 = coerceArray(coerceString)(raw);
expect(r1).toEqual(r2);
}
),
{ numRuns: 100 }
);
});
});

View File

@@ -0,0 +1,89 @@
import { FALSY_VALUES, TRUTHY_VALUES } from "../../constants/index.js";
import { CoercionError } from "../../errors/env-error.js";
import type { CoerceFn } from "./coerce.types.js";
/**
* Coerce a raw string to a trimmed string (identity with trim).
*/
export const coerceString: CoerceFn<string> = (raw) => raw.trim();
/**
* Coerce a raw string to a floating-point number.
*
* @throws {CoercionError} When the string is empty or not a valid number.
*/
export const coerceNumber: CoerceFn<number> = (raw) => {
const trimmed = raw.trim();
if (trimmed === "") {
throw new CoercionError("", trimmed, "number");
}
const value = Number(trimmed);
if (Number.isNaN(value)) {
throw new CoercionError("", trimmed, "number");
}
return value;
};
/**
* Coerce a raw string to an integer.
*
* @throws {CoercionError} When the string is not a valid integer.
*/
export const coerceInteger: CoerceFn<number> = (raw) => {
const trimmed = raw.trim();
const value = Number(trimmed);
if (Number.isNaN(value) || !Number.isInteger(value)) {
throw new CoercionError("", trimmed, "integer");
}
return value;
};
/**
* Coerce a raw string to a boolean.
*
* Recognises standard truthy tokens: `true`, `1`, `yes`, `on`, `enabled`.
* Recognises standard falsy tokens: `false`, `0`, `no`, `off`, `disabled`, `""`.
*
* @throws {CoercionError} When the string doesn't match any known token.
*/
export const coerceBoolean: CoerceFn<boolean> = (raw) => {
const lower = raw.trim().toLowerCase();
if (TRUTHY_VALUES.includes(lower)) return true;
if (FALSY_VALUES.includes(lower)) return false;
throw new CoercionError("", raw, "boolean");
};
/**
* Coerce a raw string to a parsed JSON value.
*
* @throws {CoercionError} When the string is not valid JSON.
*/
export const coerceJson: CoerceFn<unknown> = (raw) => {
try {
return JSON.parse(raw.trim());
} catch {
throw new CoercionError("", raw, "JSON");
}
};
/**
* Create a coercion function that splits a string into an array and
* applies `itemCoerce` to each element.
*
* @param itemCoerce - Coercion function for individual items.
* @param separator - The delimiter to split on. Defaults to `","`.
* @returns A coercion function producing `T[]`.
*/
export function coerceArray<T>(
itemCoerce: CoerceFn<T>,
separator = ","
): CoerceFn<T[]> {
return (raw: string) => {
if (raw.trim() === "") return [];
return raw
.split(separator)
.map((s) => s.trim())
.filter((s) => s.length > 0)
.map(itemCoerce);
};
}

View File

@@ -0,0 +1,8 @@
/** A function that converts a raw environment string into a typed value. */
export type CoerceFn<T> = (raw: string) => T;
/** Registry entry pairing a named coercion function with its name. */
export interface CoerceEntry<T = unknown> {
readonly name: string;
readonly coerce: CoerceFn<T>;
}

View File

@@ -0,0 +1,9 @@
export {
coerceArray,
coerceBoolean,
coerceInteger,
coerceJson,
coerceNumber,
coerceString,
} from "./coerce";
export type { CoerceEntry, CoerceFn } from "./coerce.types";

View File

@@ -0,0 +1,10 @@
export {
type CoerceEntry,
type CoerceFn,
coerceArray,
coerceBoolean,
coerceInteger,
coerceJson,
coerceNumber,
coerceString,
} from "./coerce";

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,103 @@
/**
* Shared type definitions for @suaveplan/env.
*/
/**
* A single validation rule applied to a field value after coercion.
*
* Uses method syntax for `validate` so that `T` is bivariant,
* allowing field builders (e.g. `StringField`) to be assignable
* to `AnyField<unknown>` without type-variance conflicts.
*/
export interface ValidationRule<T = unknown> {
readonly name: string;
readonly message: string;
validate(value: T): boolean;
}
/** Runtime configuration extracted from a field builder. */
export interface FieldConfig<T = unknown> {
readonly isRequired: boolean;
readonly hasDefault: boolean;
readonly defaultValue?: T;
readonly isSensitive: boolean;
readonly description?: string;
readonly rules: ReadonlyArray<ValidationRule<T>>;
}
/** Result returned by an environment loader. */
export interface LoaderResult {
readonly values: Record<string, string>;
readonly source: string;
}
/** An environment source loader. */
export interface Loader {
readonly name: string;
load(): LoaderResult;
}
/** How to handle validation errors. */
export type ErrorMode = "throw" | "collect" | "warn";
/** A single environment validation error value object. */
export interface EnvError {
readonly key: string;
readonly message: string;
readonly value?: string | undefined;
}
/** Minimal shape every field builder must satisfy. */
export interface AnyField<T = unknown, R extends boolean = boolean> {
readonly _phantom?: { type: T; required: R };
_isRequired: boolean;
_hasDefault: boolean;
_defaultValue?: T;
_isSensitive: boolean;
_description?: string;
_rules: Array<ValidationRule<T>>;
coerce(raw: string): T;
}
/** A record of field builders keyed by environment variable name. */
export type EnvSchema = Record<string, AnyField>;
/** Extract the value type from a field. */
export type InferFieldType<F> =
F extends AnyField<infer T, boolean> ? T : never;
/** Keys whose fields are always present in the output. */
export type RequiredKeys<S extends EnvSchema> = {
[K in keyof S]: S[K] extends AnyField<unknown, true> ? K : never;
}[keyof S];
/** Keys whose fields may be undefined in the output. */
export type OptionalKeys<S extends EnvSchema> = {
[K in keyof S]: S[K] extends AnyField<unknown, false> ? K : never;
}[keyof S];
/** Flatten an intersection type into a readable single-object shape. */
export type Simplify<T> = { [K in keyof T]: T[K] } & object;
/** Infer the fully-typed output record from an env schema. */
export type InferEnv<S extends EnvSchema> = Simplify<
{ [K in RequiredKeys<S>]: InferFieldType<S[K]> } & {
[K in OptionalKeys<S>]?: InferFieldType<S[K]>;
}
>;
/** Options for `createEnv`. */
export interface CreateEnvOptions<S extends EnvSchema = EnvSchema> {
readonly schema: S;
/**
* Direct source object (e.g. `process.env`). Applied after loaders.
* Source takes priority over loader values.
*/
readonly source?: Record<string, string | undefined>;
/** Ordered list of loaders; later loaders override earlier ones. */
readonly loaders?: ReadonlyArray<Loader>;
/** Optional key prefix stripped when looking up schema keys. */
readonly prefix?: string;
/** How to handle validation errors. Defaults to `'throw'`. */
readonly onError?: ErrorMode;
}

View File

@@ -0,0 +1,16 @@
export type {
AnyField,
CreateEnvOptions,
EnvError,
EnvSchema,
ErrorMode,
FieldConfig,
InferEnv,
InferFieldType,
Loader,
LoaderResult,
OptionalKeys,
RequiredKeys,
Simplify,
ValidationRule,
} from "./common";

View File

@@ -0,0 +1,5 @@
export {
extractVariables,
hasInterpolation,
interpolate,
} from "./interpolation";

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.

View File

@@ -0,0 +1,100 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
extractVariables,
hasInterpolation,
interpolate,
} from "./interpolation.js";
describe("interpolate", () => {
it("replaces ${VAR} tokens", { timeout: 5000 }, () => {
expect(interpolate("hello ${NAME}", { NAME: "alice" })).toBe(
"hello alice"
);
});
it("replaces $VAR tokens", { timeout: 5000 }, () => {
expect(interpolate("hello $NAME", { NAME: "bob" })).toBe("hello bob");
});
it("uses defaultValue for unknown vars", { timeout: 5000 }, () => {
expect(interpolate("${UNKNOWN}", {}, "fallback")).toBe("fallback");
});
it("uses empty string as default defaultValue", { timeout: 5000 }, () => {
expect(interpolate("${UNKNOWN}", {})).toBe("");
});
it("replaces multiple tokens", { timeout: 5000 }, () => {
expect(interpolate("${A} and ${B}", { A: "foo", B: "bar" })).toBe(
"foo and bar"
);
});
it("handles no tokens", { timeout: 5000 }, () => {
expect(interpolate("no tokens here", {})).toBe("no tokens here");
});
it("handles empty string", { timeout: 5000 }, () => {
expect(interpolate("", {})).toBe("");
});
it("handles match where both braced and bare are undefined (edge case)", {
timeout: 5000,
}, () => {
// The ?? "" branch fires when braced and bare are both undefined
// This is covered by the regex itself — a normal token always has one group
// Force it by testing with a valid token that has no named group
const result = interpolate("${KNOWN}", { KNOWN: "val" });
expect(result).toBe("val");
});
});
describe("hasInterpolation", () => {
it("returns true for ${VAR} pattern", { timeout: 5000 }, () => {
expect(hasInterpolation("${VAR}")).toBe(true);
});
it("returns true for $VAR pattern", { timeout: 5000 }, () => {
expect(hasInterpolation("$VAR")).toBe(true);
});
it("returns false for plain strings", { timeout: 5000 }, () => {
expect(hasInterpolation("hello world")).toBe(false);
});
it("returns false for empty string", { timeout: 5000 }, () => {
expect(hasInterpolation("")).toBe(false);
});
it("is safe to call multiple times", { timeout: 5000 }, () => {
// Ensures lastIndex reset works correctly
expect(hasInterpolation("${A}")).toBe(true);
expect(hasInterpolation("${A}")).toBe(true);
expect(hasInterpolation("plain")).toBe(false);
expect(hasInterpolation("plain")).toBe(false);
});
});
describe("extractVariables", () => {
it("extracts ${VAR} names", { timeout: 5000 }, () => {
expect(extractVariables("${FOO} and ${BAR}")).toEqual(["FOO", "BAR"]);
});
it("extracts $VAR names", { timeout: 5000 }, () => {
expect(extractVariables("$FOO and $BAR")).toEqual(["FOO", "BAR"]);
});
it("deduplicates variable names", { timeout: 5000 }, () => {
expect(extractVariables("${A} ${A} ${B}")).toEqual(["A", "B"]);
});
it("returns empty array for no tokens", { timeout: 5000 }, () => {
expect(extractVariables("no tokens")).toEqual([]);
});
it("handles mixed patterns", { timeout: 5000 }, () => {
const vars = extractVariables("${FOO} and $BAR");
expect(vars).toContain("FOO");
expect(vars).toContain("BAR");
});
});

Some files were not shown because too many files have changed in this diff Show More