9.6 KiB
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<string, AnyField>) 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<S> whose type is fully inferred from the schema via InferEnv<S>. 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): throwEnvValidationErrororMissingEnvErroron the first field failure.'collect': accumulate all field failures intoCollectedErrors(aRecord<string, string[]>) without throwing; return the partial result alongside the error map.'warn': log each failure viaconsole.warnand return the partial result; do not throw.
ENV-03 — Loader contract
A Loader MUST implement { name: string; load(): LoaderResult }. LoaderResult MUST be { values: Record<string, string>; 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<string, string | undefined> 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<string, string>. 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<T> MUST provide the following fluent builder methods, each returning this for chaining:
optional()— marks the field as not required; returnsundefinedwhen the key is absent and there is no defaultdefault(v: T)— sets a default value used when the key is absentsensitive()— marks the field somaskSensitiveredacts its value in logs/outputdescribe(s: string)— attaches a human-readable descriptionrules(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,nonemptyNumberField—parseFloat; rejectsNaN; built-in rules:min,max,integerBooleanField— mapsTRUTHY_VALUESstrings totrue,FALSY_VALUESstrings tofalse; any other value is a coercion errorEnumField<V>— validates raw string is a member of the declaredEnumValuestupleUrlField— validates vianew URL(); optionalprotocolsallowlist rule rejects disallowed schemesEmailField— validates againstEMAIL_PATTERNregexPortField— coerces to integer; validates in[PORT_MIN, PORT_MAX]JsonField<T>—JSON.parse; applies optionalJsonValidator<T>for shape checkingArrayField<T>— splits onseparator(defaultDEFAULT_ARRAY_SEPARATOR = ','), trims each item, maps throughitemField.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— validatesFieldConfig<unknown>ValidationResultSchema— validatesValidationResultCreateEnvOptionsSchema— validatesCreateEnvOptionsDotenvLoaderOptionsSchema— validatesDotenvLoaderOptionsObjectLoaderOptionsSchema— validatesObjectLoaderOptions
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.totalwith labeloutcome: 'success' | 'error'— incremented once percreateEnvinvocation after all fields are processed - Counter
env.validation.errorswith labelfield: string— incremented once per field that fails validation or coercion - Histogram
env.load.duration_ms— records the wall-clock duration of the fullcreateEnvcall
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,
validateMUST returnerrors.length === N parseDotenv(content)MUST be pure: samecontentinput 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.