# env ## Purpose Type-safe environment variable loading with schema-first validation. Provides typed field builders (string, number, boolean, enum, url, email, port, json, array), pluggable loaders (object, dotenv) with `${VAR}` interpolation, three error modes (`throw`/`collect`/`warn`), preset bundles for common server fields, and structured `EnvError` codes via `@suaveplan/error`. ## Requirements ### ENV-01 — Schema-first type-safe loading `createEnv(schema, options?)` MUST accept an `EnvSchema` (a `Record`) and an optional `CreateEnvOptions`. It MUST run all configured loaders in order, merge their `LoaderResult.values` maps (later loaders win by default), resolve `${VAR}` interpolation tokens, coerce each raw string through its field's `coerce()` method, run all registered validation rules, and return an `EnvResult` whose type is fully inferred from the schema via `InferEnv`. The returned object MUST be typed so that required fields are never `undefined` and optional fields may be `undefined`. ### ENV-02 — Error mode contract `createEnv` MUST support three `ErrorMode` values: - `'throw'` (default): throw `EnvValidationError` or `MissingEnvError` on the first field failure. - `'collect'`: accumulate all field failures into `CollectedErrors` (a `Record`) without throwing; return the partial result alongside the error map. - `'warn'`: log each failure via `console.warn` and return the partial result; do not throw. ### ENV-03 — Loader contract A `Loader` MUST implement `{ name: string; load(): LoaderResult }`. `LoaderResult` MUST be `{ values: Record; source: string }`. `createEnv` MUST call each loader's `load()` in the order they appear in `options.loaders`. If no loaders are provided the raw values map is empty and only defaults and optional fields can produce output. ### ENV-04 — objectLoader `objectLoader(obj, options?)` MUST wrap any `Record` as a `Loader`. `ObjectLoaderOptions.source` (optional) sets the `LoaderResult.source` label; defaults to `'object'`. Values that are `undefined` MUST be omitted from `LoaderResult.values`. ### ENV-05 — dotenvLoader and parseDotenv `parseDotenv(content)` MUST be a pure function that parses a dotenv-format string into a `Record`. It MUST handle: quoted values (single and double), inline comments (`# …`), blank lines, and `=` characters inside values. Malformed lines that cannot be parsed MUST cause a `DotenvParseError` (code `ENV.PARSE_ERROR`). `dotenvLoader(options?)` MUST read the file at `options.path` (default `'.env'`), call `parseDotenv`, and return a `LoaderResult`. If the file does not exist the loader MUST return an empty `LoaderResult` (no error); this allows optional `.env` files. `DotenvLoaderOptions.override` (boolean, default `true`) controls whether dotenv values overwrite values already present from earlier loaders. ### ENV-06 — Interpolation `interpolate(template, values)` MUST replace all `${VAR}` tokens in `template` with the corresponding entry from `values`, leaving unresolved tokens as-is. `hasInterpolation(value)` MUST return `true` if and only if the string contains at least one `${VAR}` token. `extractVariables(template)` MUST return all unique variable names referenced in the template. `createEnv` MUST expand interpolation on every raw value before coercion. ### ENV-07 — BaseField builder contract `BaseField` MUST provide the following fluent builder methods, each returning `this` for chaining: - `optional()` — marks the field as not required; returns `undefined` when the key is absent and there is no default - `default(v: T)` — sets a default value used when the key is absent - `sensitive()` — marks the field so `maskSensitive` redacts its value in logs/output - `describe(s: string)` — attaches a human-readable description - `rules(opts: RuleOptions)` — appends a named custom validation rule `BaseField` MUST declare `abstract coerce(raw: string): T`. Calling `coerce` with an uncoercible string MUST throw `CoercionError` (code `ENV.INVALID_FORMAT`). ### ENV-08 — Field type coercion and rules Each field type MUST coerce its raw string through the corresponding transform and enforce its built-in rules before any custom rules run: - `StringField` — identity coercion (trim); built-in rules: `minLength`, `maxLength`, `pattern`, `nonempty` - `NumberField` — `parseFloat`; rejects `NaN`; built-in rules: `min`, `max`, `integer` - `BooleanField` — maps `TRUTHY_VALUES` strings to `true`, `FALSY_VALUES` strings to `false`; any other value is a coercion error - `EnumField` — validates raw string is a member of the declared `EnumValues` tuple - `UrlField` — validates via `new URL()`; optional `protocols` allowlist rule rejects disallowed schemes - `EmailField` — validates against `EMAIL_PATTERN` regex - `PortField` — coerces to integer; validates in `[PORT_MIN, PORT_MAX]` - `JsonField` — `JSON.parse`; applies optional `JsonValidator` for shape checking - `ArrayField` — splits on `separator` (default `DEFAULT_ARRAY_SEPARATOR = ','`), trims each item, maps through `itemField.coerce` ### ENV-09 — validate accumulates all errors `validate(input: ValidateInput)` MUST run all rules in `input.field._rules` and collect every failure. The returned `ValidationResult` MUST be `{ ok: boolean; errors: Array<{ rule: string; message: string }> }` where `errors` contains one entry per failing rule. `ok` MUST be `true` if and only if `errors` is empty. ### ENV-10 — maskSensitive never leaks `maskSensitive(values, schema)` MUST return a shallow copy of `values` where every key whose corresponding `schema[key]._isSensitive` is `true` has its value replaced with the literal string `'***'`. The original `values` object MUST NOT be mutated. ### ENV-11 — Structured errors with ENV.* codes All errors thrown by this package MUST extend the `EnvError` base (which extends `SuaveplanError`). Error codes MUST be registered via `defineCodes` in the `ENV.*` namespace: | Code | Class | Trigger | |---|---|---| | `ENV.VALIDATION_FAILED` | `EnvValidationError` | One or more field rules failed | | `ENV.MISSING_REQUIRED` | `MissingEnvError` | Required field has no value and no default | | `ENV.INVALID_FORMAT` | `CoercionError` | Raw string cannot be coerced to target type | | `ENV.PARSE_ERROR` | `DotenvParseError` | Dotenv file or JSON value cannot be parsed | Every error instance MUST carry `{ key: string; raw?: string; source?: string }` in its structured context. ### ENV-12 — Zod schemas for runtime validation The following Zod schemas MUST be exported from `*.schemas.ts` sibling files and used to validate inputs at public API boundaries: - `FieldConfigSchema` — validates `FieldConfig` - `ValidationResultSchema` — validates `ValidationResult` - `CreateEnvOptionsSchema` — validates `CreateEnvOptions` - `DotenvLoaderOptionsSchema` — validates `DotenvLoaderOptions` - `ObjectLoaderOptionsSchema` — validates `ObjectLoaderOptions` Invalid options passed to `createEnv`, `dotenvLoader`, or `objectLoader` MUST throw `EnvValidationError` with code `ENV.VALIDATION_FAILED` and a context that includes the Zod parse error details. ### ENV-13 — Presets The common preset fields (`nodeEnvField`, `portField`, `hostField`, `logLevelField`, `debugField`, `appNameField`, `appVersionField`, `timezoneField`, `workersField`) and the `commonFields` composite MUST be exported from `src/presets/common/`. The server preset fields (`databaseUrlField`, `redisUrlField`, `sessionSecretField`, `corsOriginField`, `rateLimitMaxField`, `rateLimitWindowField`, `trustProxyField`, `smtpHostField`, `smtpPortField`, `smtpUserField`, `smtpPassField`, `smtpFromField`) and the `serverFields` composite MUST be exported from `src/presets/server/`. All sensitive preset fields (database URL, Redis URL, session secret, SMTP password) MUST have `_isSensitive = true`. ### ENV-14 — OTel instrumentation `createEnv` MUST emit the following metrics via `@opentelemetry/api` `getMeter`: - Counter `env.validation.total` with label `outcome: 'success' | 'error'` — incremented once per `createEnv` invocation after all fields are processed - Counter `env.validation.errors` with label `field: string` — incremented once per field that fails validation or coercion - Histogram `env.load.duration_ms` — records the wall-clock duration of the full `createEnv` call All instruments MUST be no-ops when no OTel SDK exporter is registered. The instrumentation MUST NOT throw or suppress errors from the calling code. ### ENV-15 — sideEffects: false + coverage 100% `"sideEffects": false` MUST be set in `package.json` and enforced by a tree-shake guard test in `tests/`. Vitest coverage thresholds MUST be set to 100/100/100/100 (statement / branch / function / line). No `.skip` or `.only` markers are permitted in `src/`. ### ENV-16 — Property-based test contracts The following fast-check properties MUST pass: - For all valid raw strings `s`, `coerce(s)` produces the same stable value on repeated calls (field coerce functions are pure) - For any schema with at least one sensitive field, `maskSensitive(values, schema)` MUST NOT contain the original raw value at any sensitive key - For any N failing rules, `validate` MUST return `errors.length === N` - `parseDotenv(content)` MUST be pure: same `content` input always produces an equal output map ### ENV-17 — No browser or Node-specific dependencies The package MUST run in Bun, Node, and browser environments. It MUST NOT import `fs`, `path`, `process`, or any Node-only built-in in any code path that does not go through `dotenvLoader`. `dotenvLoader` itself MAY use file-system access; all other modules MUST be environment-agnostic.