# Browser (/docs/browser)
In the browser there is no filesystem and no `process.env`. Your bundler inlines configuration at build time, and you seed a `PortableSource` from it, then read with the same typed API as everywhere else.
Import from `envapt`. The package export conditions resolve a node-free portable build in the browser automatically, so the same import works in local dev and in the bundle.
The filesystem-only config APIs have nothing to do in the browser, so they warn and no-op by default. Readers work as normal. See [Compatibility](/docs/compatibility) for `fileApiMode` and [Errors](/docs/errors) for the codes.
```ts twoslash
import { Envapter } from 'envapt';
Envapter.getBoolean('FEATURE_BETA', false); // readers work
Envapter.baseDir = '/tmp'; // warns once and no-ops in the browser
```
## Seed from your bundler [#seed-from-your-bundler]
Bundlers replace build-time env references with string literals: Vite exposes them on `import.meta.env`, webpack through `DefinePlugin`. Pass that object to `PortableSource`, then read the values typed.
```ts
import { Envapter, PortableSource, Converters } from 'envapt';
Envapter.useSource(new PortableSource(import.meta.env));
const apiUrl = Envapter.getUsing('VITE_API_URL', Converters.Url); // URL, not a raw string
const beta = Envapter.getBoolean('VITE_ENABLE_BETA', false); // boolean
```
`import.meta.env.VITE_API_URL` is a string; `Envapter.getUsing('VITE_API_URL', Converters.Url)` returns a `URL`, and `getBoolean` returns a `boolean`. `PortableSource` snapshots the object and JSON-stringifies non-string values, so you can read Vite's boolean `DEV` and `PROD` flags too.
Anything you seed into a browser `PortableSource` ships in your client bundle. Pass public configuration only,
never a secret or a server-side key.
## Environment detection [#environment-detection]
`Envapter.environment` and the `isProduction` / `isDevelopment` / `isTest` helpers also read `MODE`, which Vite sets to `development`, `production`, or your `--mode` value. Because you bind `import.meta.env`, they work with no extra wiring. See [Environment](/docs/environment).
## No filesystem [#no-filesystem]
`PortableSource` snapshots its object at construction, so mutating that object afterward does not change what envapt reads. There is no `.env` cascade in the browser, and reading before you bind a source throws `NoSourceBound`. See [Sources](/docs/sources).
## Decorators [#decorators]
The default `@Envapt` accessor decorators work in the browser build, the legacy `envapt/legacy` form needs a compile step.
For the providers and the contract see [Sources](/docs/sources).
# Compatibility (/docs/compatibility)
envapt runs on Node, Bun, and Deno with the same API and zero runtime dependencies. It also runs on Cloudflare Workers (workerd), other edge runtimes, and the browser, where you bind the source yourself (a `PortableSource`) and read with the same typed API. The functional reader API works on every runtime with no build step. The default `@Envapt` accessor decorators need no flag either. The legacy `envapt/legacy` decorators need `experimentalDecorators` on every runtime.
## Binding by runtime [#binding-by-runtime]
envapt splits into two builds, selected by the package `exports` conditions. On the node build a source binds on import. On the portable build you bind one yourself.
### Node build, binds automatically [#node-build-binds-automatically]
`FileSource` binds on import and reads `process.env` plus the `.env` cascade, so the first read works with no setup.
{/* prettier-ignore-start */}
| Runtime | Condition | Reads | Gotcha |
| ----------- | --------- | --------------------------------------------- | ------------------------------------------ |
| Node | `node` | `process.env` and the `.env` cascade | -- |
| Bun | `node` | `process.env` and the `.env` cascade | -- |
| Deno | `deno` | `process.env` and the `.env` cascade | needs Deno 2.5+ |
| Deno Deploy | `deno` | dashboard vars via `process.env` / `Deno.env` | no `.env` files, set vars in the dashboard |
{/* prettier-ignore-end */}
### Portable build, you bind a source [#portable-build-you-bind-a-source]
Bind a `PortableSource` from the object the platform gives you before your first read. An unbound read throws [`NoSourceBound`](/docs/errors), and the file-only APIs warn once and no-op (see `fileApiMode` below).
{/* prettier-ignore-start */}
| Runtime | Condition | Seed from | `process.env` | Gotcha |
| ------------------------------------------ | -------------- | ---------------------------- | ------------- | ------------------------------------------------- |
| [Cloudflare Workers](/docs/workers) | `workerd` | the `env` binding | no | bind once at module load |
| [Vercel Edge](/docs/edge-runtimes) | `edge-light` | `process.env` | yes | deprecated by Vercel, the Node runtime auto-binds |
| [Fastly Compute](/docs/edge-runtimes) | `fastly` | `env()` keys, no bulk object | no | add the `fastly` condition |
| [Expo / React Native](/docs/edge-runtimes) | `react-native` | `Constants.expoConfig.extra` | build-inlined | `process.env` is not a runtime object |
| [Browser](/docs/browser) | `browser` | bundler-injected config | no | ships in the client bundle |
{/* prettier-ignore-end */}
## Engine versions [#engine-versions]
{/* prettier-ignore-start */}
| Runtime | Minimum |
| ------- | ---------- |
| Node | `>=20` |
| Bun | `>=1.3.11` |
| Deno | `>=2.5` |
{/* prettier-ignore-end */}
Each release is tested against Node 20, 22, and 24, plus Bun and Deno.
Cloudflare Workers and the browser have no semver engine. The package `exports` conditions (below) select the build there.
## Install [#install]
pnpm
npm
yarn
bun
deno
```bash
pnpm add envapt
```
```bash
npm install envapt
```
```bash
yarn add envapt
```
```bash
bun add envapt
```
```bash
deno add jsr:@materwelon/envapt
```
The Deno tab installs from JSR. You can also use the npm specifier with `deno add npm:envapt`.
## Module formats [#module-formats]
envapt ships both ESM and CommonJS with type definitions for each, so `import` and `require` both resolve. There are no runtime dependencies.
## Portable and node builds [#portable-and-node-builds]
The package `exports` conditions select between two builds:
* **The portable build** resolves from bare `envapt` on Workers, edge runtimes (`edge-light`, `fastly`, `worker`), the browser, and react-native. It contains no `node:` imports. Bind a [`PortableSource`](/docs/sources) and read with the same typed API.
* **The node build** also resolves from bare `envapt` on Node, Bun, and Deno. `FileSource` is bound on import, so it reads `process.env` and the `.env` cascade, and the file APIs work.
Bind a source before your first read on the portable build. See [Sources](/docs/sources).
Import from `envapt` on every runtime, no subpath import is needed. On the portable build, the file-only config APIs (`envPaths`, `baseDir`, `envFileOptions`, `configureProfiles`, `resetProfiles`) warn once and no-op by default. Set `Envapter.fileApiMode = 'throw'` to make them throw [`FileApiUnsupported`](/docs/errors). `fileApiMode` governs only the portable file-API behavior. On the node build a bare (non-filesystem) source still throws `FileApiUnsupported` when a file API is called. An unbound read throws `NoSourceBound` on all runtimes regardless of `fileApiMode`.
Deno and Deno Deploy resolve the node build by design. On Deno the `.env` cascade and file APIs work as on Node. Deno Deploy has no `.env` files on disk, so set variables in its dashboard and read them through `process.env` / `Deno.env`.
## Decorators per runtime [#decorators-per-runtime]
The default `@Envapt` import is a TC39 (Stage 3) accessor decorator. It needs no `experimentalDecorators` flag and works on every runtime, including Bun and Deno running a `.ts` file directly.
The legacy decorators at `envapt/legacy` are the older TypeScript decorators, so they need `experimentalDecorators`. The functional API needs no flag on any runtime.
**Node and Deno.** The default accessor decorators work with no configuration. The legacy form needs the flag set in `tsconfig.json` on Node or `deno.json` on Deno.
```json title="tsconfig.json / deno.json"
{ "compilerOptions": { "experimentalDecorators": true } }
```
**Bun.** The functional API and both decorator forms work when you run a `.ts` file directly. The legacy form reads `experimentalDecorators` from `tsconfig.json`, same as Node.
Bun honors `experimentalDecorators` from 1.3.11 onward ([bun#27575](https://github.com/oven-sh/bun/issues/27575)).
Bun 1.3.10 ignores the flag and emits Stage 3 decorators, so a legacy `@Envapt` field throws a `TypeError` when the
class is defined.
# Configuration (/docs/configuration)
On Node, Bun, and Deno the default source reads `process.env` plus the `.env` files it loads. Static settings on `Envapter` control which files load, whether loaded values reach `process.env`, and how much envapt logs. They apply only while a Node source is active, on Workers and in the browser you bind a [source](/docs/sources) instead.
```ts twoslash
import { Envapter } from 'envapt';
// ---cut---
Envapter.envPaths = ['.env.local', '.env'];
Envapter.envFileOptions = { override: true };
Envapter.syncProcessEnv = true;
Envapter.debug = 'verbose';
```
envapt loads `.env` files into an isolated copy of `process.env`, so by default it does not mutate the real
`process.env`. Values are read from envapt's own cache. `syncProcessEnv` (below) opts into writing them back.
Everything on this page reads or writes the filesystem, so it applies to Node, Bun, and Deno only. On the portable
build (Cloudflare Workers, the browser, and edge runtimes) these APIs warn and no-op by default. Bind an environment
source instead. See [Compatibility](/docs/compatibility).
## Which files load [#which-files-load]
By default envapt loads `.env`, and in a detected environment it layers a per-environment cascade (covered in [Environment](/docs/environment)).
Set `envPaths` to load a fixed, ordered list instead. Earlier paths win.
```ts twoslash
import { Envapter } from 'envapt';
// ---cut---
Envapter.envPaths = ['config/app.env', '.env'];
```
Every path you list must exist. Setting `envPaths` validates the paths immediately, and a missing file throws
`EnvaptError` with code `EnvFilesNotFound`. Setting `envPaths` also takes precedence: it disables the
per-environment auto-cascade and any `configureProfiles` paths. Relative paths resolve against `process.cwd()`
unless you set `Envapter.baseDir` (below).
`envFileOptions` tunes how files are read. `override` (default `false`) decides whether a later file or a file value can replace an already-set key; `encoding` (default `utf8`) sets the file encoding.
```ts twoslash
import { Envapter } from 'envapt';
// ---cut---
Envapter.envFileOptions = { override: true, encoding: 'utf8' };
```
## Reading from a fixed directory [#reading-from-a-fixed-directory]
Relative paths resolve against the working directory (`process.cwd()`) by default. In a monorepo the process often starts at the repository root, not the package directory, so a package-local `.env` is not found. Set `Envapter.baseDir` to the directory that relative paths resolve against: the auto-cascade, `configureProfiles` paths, and relative `envPaths`. Absolute paths are used unchanged.
```ts twoslash
import { Envapter } from 'envapt';
// ---cut---
// ESM: resolve .env next to this module, whatever the working directory is
Envapter.baseDir = import.meta.url;
// CommonJS
Envapter.baseDir = __dirname;
```
`baseDir` accepts a directory path, a module URL (`import.meta.url`), or `import.meta.dirname` / `__dirname`. Set it before `envPaths` so relative `envPaths` validate against the same directory. Leave it unset to keep `process.cwd()` resolution.
## Mirroring to process.env [#mirroring-to-processenv]
By default a loaded value stays in envapt's cache. Set `syncProcessEnv` to mirror the keys envapt loaded back into `process.env`, so other libraries that read `process.env` directly get them.
```ts twoslash
import { Envapter } from 'envapt';
// ---cut---
Envapter.syncProcessEnv = true;
```
Only keys envapt's loader wrote are mirrored, so collisions follow `envFileOptions.override`: with the default `false`, a pre-existing `process.env` value is preserved.
Mirrored values are resolved first, so `${VAR}` templates are expanded before they reach `process.env` and match what `Envapter.get` returns.
Mirroring is one-way. Turning `syncProcessEnv` back to `false` does not remove keys already written; they stay in
`process.env` until the process exits.
## Debug logging [#debug-logging]
`debug` controls how much envapt writes to stderr (prefixed `[envapt]`). The levels are `silent` (default), `warn`, and `verbose`.
```ts twoslash
import { Envapter } from 'envapt';
// ---cut---
Envapter.debug = 'verbose';
```
* `warn`: signals that may indicate misconfiguration: failed file reads, unresolved templates in non-strict mode, and any read of a missing or empty variable, or under strict mode a whitespace-only one (whether it returns a fallback or `undefined`).
* `verbose`: adds the base directory, every loaded file, per-file key counts, per-key load lines, effective paths, cache rebuilds, and any present value a built-in converter could not parse (a malformed value that fell back to its default).
You can also set the level without code through the `ENVAPT_DEBUG` environment variable. envapt reads it on first access; an explicit `Envapter.debug` assignment overrides it.
```dotenv
ENVAPT_DEBUG=verbose
```
`import 'envapt/config'` loads at import time. Set `ENVAPT_DEBUG=verbose` before the import to see that load logged.
An `Envapter.debug` assignment in code runs after the load, so it is too late to log it.
# Converters (/docs/converters)
A converter turns the raw environment string into a typed value. Pass a built-in token to `getUsing`, a function to `getWith`, or either as the `converter` option on the [`@Envapt`](/docs/decorators) decorator. Without a converter, a value stays a `string`.
```ts twoslash
import { Envapter, Converters } from 'envapt';
// ---cut---
const timeout = Envapter.getUsing('TIMEOUT', Converters.Time, '30s');
// ^?
const retries = Envapter.getWith('RETRIES', (raw, fallback) => (raw ? Number(raw) : fallback), 3);
// ^?
```
## Built-in tokens [#built-in-tokens]
`Converters` carries one token per built-in type, plus the `array` builder.
{/* prettier-ignore-start */}
| Token | Returns | Parsed from |
| -------------------- | ----------- | --------------------------------------------------------- |
| `Converters.String` | `string` | the raw value, unchanged |
| `Converters.Number` | `number` | `Number(raw)`; `NaN` uses the fallback |
| `Converters.Integer` | `number` | `Number(raw)`, must be a safe integer |
| `Converters.Float` | `number` | `Number(raw)`, `NaN` uses the fallback |
| `Converters.Boolean` | `boolean` | `1`/`yes`/`true`/`on` and `0`/`no`/`false`/`off` |
| `Converters.Bigint` | `bigint` | `BigInt(raw)` |
| `Converters.Symbol` | `symbol` | `Symbol.for(raw)` |
| `Converters.Json` | `JsonValue` | `JSON.parse(raw)` |
| `Converters.Url` | `URL` | `new URL(raw)` |
| `Converters.Regexp` | `RegExp` | `/pattern/flags` form, or the whole string as the pattern |
| `Converters.Date` | `Date` | a millisecond timestamp or a UTC ISO 8601 string |
| `Converters.Time` | `number` | a duration string like `30s`, returned as milliseconds |
| `Converters.Port` | `number` | an integer in the `0-65535` range |
| `Converters.Email` | `string` | an address matching the WHATWG email pattern |
{/* prettier-ignore-end */}
```ts twoslash
import { Envapter, Converters } from 'envapt';
// ---cut---
const port = Envapter.getUsing('PORT', Converters.Number, 3000);
// ^?
const apiUrl = Envapter.getUsing('API_URL', Converters.Url);
// ^?
```
A fallback removes `undefined` from the return type, the same way it does for the [primitive readers](/docs/envapter#fallbacks-shape-the-return-type). Without one, the return is `T | undefined`.
The fallback must itself be valid for the converter (a real email, an in-range port, a non-`NaN` number, a valid `Date`). An invalid one throws `FallbackConverterTypeMismatch`.
No converter throws on a failed conversion. The converter returns the fallback, or `undefined` when you gave none.
Use `getRequired` to throw on a malformed or missing value instead.
## Number, Integer, and Float [#number-integer-and-float]
`Number` accepts anything JavaScript's `Number()` does, including floats, hex, and scientific notation. `Integer` and `Float` now parse with `Number` as well. `Integer` requires a safe integer, so `42px`, `3.9`, and values past 2^53 fall back. `Float` rejects trailing text like `3.14px` and keeps `Infinity` like `Number`. Both accept the same literal forms as `Number`, including `0x`, `0o`, and `0b`.
```ts twoslash
import { Envapter, Converters } from 'envapt';
// ---cut---
// MAX_CONNECTIONS=100
const maxConnections = Envapter.getUsing('MAX_CONNECTIONS', Converters.Integer); // 100
// PORT="8080abc" is not a clean integer, so it falls back
const port = Envapter.getUsing('PORT', Converters.Integer, 3000); // 3000
const sampleRate = Envapter.getUsing('SAMPLE_RATE', Converters.Float, 1.0);
```
## JSON [#json]
`Json` runs `JSON.parse`. Invalid JSON returns the fallback. The return type is `JsonValue`, the recursive union of JSON-representable values, so narrow it before use.
```ts twoslash
import { Envapter, Converters } from 'envapt';
// ---cut---
// FEATURE_FLAGS={"beta":true}
const flags = Envapter.getUsing('FEATURE_FLAGS', Converters.Json, {});
// ^?
```
## URL, RegExp, Date [#url-regexp-date]
`Url` builds a `URL`, so the value must be absolute; a relative path throws inside the constructor and returns the fallback.
`Regexp` reads a `/pattern/flags` literal (flags limited to `g i m s u v y`). A value without the surrounding slashes is treated as the whole pattern with no flags.
`Date` accepts exactly two forms:
```ts twoslash
import { Envapter, Converters } from 'envapt';
// ---cut---
// RELEASED_AT=2024-01-15T10:30:00Z
const released = Envapter.getUsing('RELEASED_AT', Converters.Date);
// ^?
```
`Date` takes an all-digit millisecond timestamp, or a UTC ISO 8601 string ending in `Z` (`2024-01-15T10:30:00Z`,
optionally with `.mmm` milliseconds). A date-only string, a local time, or an offset like `+02:00` returns the
fallback. Store dates in UTC.
## Time and durations [#time-and-durations]
`Time` reads a duration and returns it in **milliseconds**. The raw value takes a number with an optional unit; a bare number is milliseconds.
```ts twoslash
import { Envapter, Converters } from 'envapt';
// ---cut---
// CACHE_TTL=15m
const ttl = Envapter.getUsing('CACHE_TTL', Converters.Time); // 900000
const grace = Envapter.getUsing('GRACE', Converters.Time, '1.5h');
// ^?
```
The units are `ms`, `s`, `m`, `h`, `d`, `w`. The fallback may be a number (already milliseconds) or a time-string.
A raw env value may omit the unit (a bare number is milliseconds). A **string fallback** must include the unit
(`90m` or `1.5h`), since a unitless number is already expressed as a number fallback (`5400`). Both forms accept
decimals. A malformed string fallback throws `EnvaptError` with code `MalformedTimeFallback`.
## Arrays [#arrays]
`Converters.array` splits the value on a delimiter and converts each element. The delimiter defaults to `,` and the element type defaults to `string`.
```ts twoslash
import { Envapter, Converters } from 'envapt';
// ---cut---
// CORS=https://a.com,https://b.com
const origins = Envapter.getUsing('CORS', Converters.array({ of: Converters.Url }));
// ^?
// PORTS=3000 3001 3002
const ports = Envapter.getUsing('PORTS', Converters.array({ of: Converters.Number, delimiter: ' ' }));
// ^?
```
The element type can be any scalar token except `Json` and `Regexp`, or your own `(raw: string) => T` function that converts a single element. An array fallback must already be an array of the element type.
```ts twoslash
import { Envapter, Converters } from 'envapt';
// ---cut---
const toUpstream = (raw: string) => {
const [host, port] = raw.split(':');
return { host, port: Number(port) };
};
// UPSTREAMS=10.0.0.1:8080,10.0.0.2:8080
const upstreams = Envapter.getUsing('UPSTREAMS', Converters.array({ of: toUpstream }));
// ^?
```
Empty elements are dropped in normal mode. Under [strict mode](/docs/strict-mode) an empty element (a trailing comma, say) throws `EnvaptError` with code `EmptyArrayElement` instead of being filtered out.
Under strict mode these throws come from env data, not your code. A trailing comma in a deployed `.env` throws
`EmptyArrayElement` at read time, and an element your converter maps to `undefined` throws
`ArrayElementConversionFailed`. A stray comma can stop startup.
## Custom converters [#custom-converters]
When no built-in fits, pass a function to `getWith`. It receives the raw value (`string`, or `undefined` when the variable is unset) and the fallback, and returns the typed value. The function owns its own parsing and failure handling.
```ts twoslash
import { Envapter } from 'envapt';
// ---cut---
const toSemver = (raw: string | undefined) => {
const [major, minor, patch] = (raw ?? '0.0.0').split('.').map(Number);
return { major, minor, patch };
};
// APP_VERSION=2.4.1
const version = Envapter.getWith('APP_VERSION', toSemver);
// ^?
```
For a single array element you supply a `(raw: string) => T` function to `array({ of })` instead. That element function receives only the trimmed, non-empty element string. Returning `undefined` from it throws `EnvaptError` with code `ArrayElementConversionFailed`.
## Require a converted value [#require-a-converted-value]
Both readers take an options form that throws on a missing value instead of returning a fallback.
```ts twoslash
import { Envapter, Converters } from 'envapt';
// ---cut---
const dbUrl = Envapter.getRequired('DATABASE_URL', Converters.Url);
// ^?
```
See [Fail-fast on missing values](/docs/envapter#fail-fast-on-missing-values) for `require`, and [Standard Schema](/docs/standard-schema) for validating a value through zod, valibot, or arktype.
# Decorators (/docs/decorators)
`@Envapt` binds a class property to an environment variable. It's the class-based counterpart to the [`Envapter`](/docs/envapter) readers, with the same parsing, converters, and cache, declared as typed fields.
The default import is a TC39 Stage 3 accessor decorator. Declare the field with the `accessor` keyword.
```ts twoslash
import { Envapt, Converters } from 'envapt';
// ---cut---
class Config {
@Envapt('PORT', { converter: Converters.Port, fallback: 3000 })
static accessor port: number;
@Envapt('DEBUG', { converter: Converters.Boolean, fallback: false })
static accessor debug: boolean;
}
Config.port;
// ^?
```
The value is read and converted once, on first access, then cached.
## Declaring decorated fields [#declaring-decorated-fields]
The right form depends on whether the field is static or instance.
* **Static** fields use `static accessor x: T`.
* **Instance** fields use `accessor x!: T`, with the `!` for definite assignment.
No `readonly`, no `declare`, no initializer, and no `experimentalDecorators` flag. This form runs on any runtime that understands Stage 3 decorators, including Bun and Deno executing a `.ts` file directly.
The accessor is read-only. It resolves from the environment, and assigning to it throws `EnvaptError`.
### Legacy decorators [#legacy-decorators]
If your project already runs on `experimentalDecorators`, import the legacy decorators from `envapt/legacy`. They keep the property-decorator form, a getter installed with `Object.defineProperty`.
* **Static** fields use a plain `static readonly x: T`, with no `declare` and no initializer.
* **Instance** fields use `declare readonly x: T`, with no initializer.
```ts
import { Envapt, Converters } from 'envapt/legacy';
class Config {
@Envapt('PORT', { converter: Converters.Port, fallback: 3000 })
static readonly port: number;
@Envapt('SESSION_TTL', { converter: Converters.Time, fallback: '15m' })
declare readonly sessionTtl: number;
}
```
Two compiler behaviors decide where the getter sits. For an instance field, `useDefineForClassFields` (on by default for modern targets) emits a per-instance assignment in the constructor that would overwrite the getter with `undefined`, so `declare` makes the field type-only and the getter survives. For a static field the reverse holds. Under TypeScript 6's tsc a `declare static` member is erased and the decorator is applied to the prototype, so the static read never reaches the getter. TypeScript 7's compiler applies it to the constructor and the read resolves. A plain `static readonly` keeps the decorator on the constructor under both compilers.
The legacy form needs `experimentalDecorators` enabled.
```json title="tsconfig.json"
{
"compilerOptions": {
"experimentalDecorators": true
}
}
```
A module that imports decorators only from `envapt/legacy` must also `import 'envapt'` once so the runtime source binds. Runtime notes are in [Compatibility](/docs/compatibility).
## Converters [#converters]
The `converter` option takes the same values as [`getUsing`/`getWith`](/docs/converters), a built-in token, a primitive constructor, a custom function, or `Converters.array(...)`. A `fallback` removes `undefined` from the value and must match the converter's type.
```ts twoslash
import { Envapt, Converters } from 'envapt';
// ---cut---
class Config {
// duration token: a time-string fallback is coerced to milliseconds
@Envapt('CACHE_TTL', { converter: Converters.Time, fallback: '15m' })
static accessor cacheTtl: number;
// array token
@Envapt('ALLOWED_ORIGINS', { converter: Converters.array({ of: Converters.String }), fallback: [] })
static accessor origins: string[];
// custom function: returns a shape the array builder can't, here a deduped Set
@Envapt('TRUSTED_IPS', { converter: (raw) => new Set((raw ?? '').split(',').filter(Boolean)) })
static accessor trustedIps: Set;
}
```
With no `converter` and no `fallback`, the property resolves to the raw string or `undefined`.
## The field type is checked [#the-field-type-is-checked]
The decorated field's declared type must hold the converter's output, or tsc rejects it. The field can be wider than the output, never narrower.
```ts twoslash
import { EnvNum } from 'envapt';
// ---cut---
class Config {
// with a fallback, the output is number
@EnvNum('MAX_RETRIES', 3)
static accessor maxRetries: number;
// without a fallback the value can be missing, so the output is number | undefined
@EnvNum('TIMEOUT')
static accessor timeout: number | undefined;
}
```
A no-fallback read makes the output `T | undefined`, and `fallback: undefined` counts the same. `required: true` makes it `T`. A field that cannot hold the output fails to compile with `[envapt] field type must hold the converter output`. The same check applies to the legacy decorators.
## Shorthand decorators [#shorthand-decorators]
`@EnvNum`, `@EnvStr`, `@EnvBool`, `@EnvUrl`, and `@EnvTime` are shorthands for `@Envapt` with a fixed converter. The call site is the key and an optional fallback.
```ts twoslash
import { EnvNum, EnvStr, EnvBool, EnvUrl, EnvTime } from 'envapt';
// ---cut---
class Config {
@EnvNum('MAX_RETRIES', 3)
static accessor maxRetries: number;
@EnvStr('AWS_REGION', 'us-east-1')
static accessor region: string;
@EnvBool('DEBUG', false)
static accessor debug: boolean;
@EnvUrl('APP_URL', new URL('http://localhost:3000'))
static accessor url: URL;
@EnvTime('CACHE_TTL', '15m')
static accessor cacheTtl: number;
}
```
The second argument is the fallback, typed to the converter, `@EnvUrl` takes a `URL`, `@EnvTime` takes a millisecond number or a time string, the rest take their primitive. Omit it and the property resolves to the converted value or `undefined`. The key can also be an [ordered list](#ordered-keys).
These are accessor decorators like `@Envapt`, so the same [declaration rule](#declaring-decorated-fields) applies. They take a fallback only. For `required`, a `schema`, an array, a port, or a custom function, use `@Envapt` with the options object.
## Required values [#required-values]
Pass `required: true` to throw `EnvaptError` on a missing or empty value, or on a whitespace-only value under strict mode. It's mutually exclusive with `fallback`.
```ts twoslash
import { Envapt, Converters } from 'envapt';
// ---cut---
class Config {
@Envapt('DATABASE_URL', { converter: Converters.Url, required: true })
static accessor databaseUrl: URL;
}
```
The throw happens on first access to the property. An unset or empty value throws in any mode. A whitespace-only value throws only under strict mode. See [Strict mode](/docs/strict-mode) for the global switch and how `required` relates to it.
First access can happen deep inside a request, so a missing required value surfaces as a runtime error
mid-request. Read the property once during startup to fail early.
## Validation with a schema [#validation-with-a-schema]
Pass a `schema` to validate through zod, valibot, arktype, or a hand-rolled `StandardSchemaV1`. The property type is the schema's output.
```ts twoslash
import { z } from 'zod/v4';
import { Envapt } from 'envapt';
// ---cut---
class Config {
@Envapt('STRIPE_SECRET_KEY', { schema: z.string().startsWith('sk_') })
static accessor stripeKey: string;
}
```
`schema` and `converter` are mutually exclusive. See [Standard Schema](/docs/standard-schema) for the full behavior.
## Ordered keys [#ordered-keys]
Like the functional readers, the key can be an ordered list. The first key with a value wins.
```ts twoslash
import { Envapt, Converters } from 'envapt';
// ---cut---
class Config {
// CANARY_URL if set, otherwise APP_URL
@Envapt(['CANARY_URL', 'APP_URL'], { converter: Converters.Url })
static accessor url: URL | undefined;
}
```
## Static and instance properties [#static-and-instance-properties]
`@Envapt` works on both static and instance properties, and the class does not need to extend anything. The cache is keyed per class and property, so a static and an instance property of the same name never collide.
Extend `Envapter` when you also want the reader methods on the same class:
```ts twoslash
import { Envapt, Converters, Envapter } from 'envapt';
// ---cut---
class Config extends Envapter {
@Envapt('PORT', { converter: Converters.Port, fallback: 3000 })
accessor port!: number;
// mix decorator fields with reader calls
readonly region = this.get('AWS_REGION', 'us-east-1');
}
const config = new Config();
config.port;
// ^?
```
A misconfigured options object throws `EnvaptError` as the decorator is applied, before any property access, `required` together with a `fallback`, or a non-`StandardSchemaV1` value passed as `schema`. See [Errors](/docs/errors) for the codes.
# Edge runtimes (/docs/edge-runtimes)
These runtimes have no filesystem and no `.env` cascade. Import from `envapt` (the portable build resolves automatically through the `edge-light`, `fastly`, and `react-native` conditions), hand envapt the config object the platform gives you once, and read with the same typed API as everywhere else. On Node-like runtimes, including Vercel's Node.js functions, `FileSource` binds on import, so even that step is automatic.
The filesystem-only config APIs have nothing to do on these runtimes, so they warn and no-op by default. Readers
work as normal. See [Compatibility](/docs/compatibility) for `fileApiMode` and [Errors](/docs/errors) for the codes.
## Vercel [#vercel]
Vercel's Node.js runtime (the default, and the one Vercel now recommends) runs the node build, so `FileSource` binds on import and reads `process.env` for you. Nothing to bind.
```ts twoslash
import { Envapter, Converters } from 'envapt';
const origin = Envapter.getRequired('ORIGIN_URL', Converters.Url);
```
The Edge runtime is the exception. Vercel is [moving away from it](https://vercel.com/docs/functions/runtimes/edge), but if you use it, it resolves the portable build (`edge-light`) and exposes your variables on `process.env` as a real object. Bind it once.
```ts twoslash
import { Envapter, PortableSource, Converters } from 'envapt';
Envapter.useSource(new PortableSource(process.env));
const origin = Envapter.getRequired('ORIGIN_URL', Converters.Url);
```
## Fastly Compute [#fastly-compute]
Fastly is the one runtime that hands you no environment object. You read values one key at a time with `env` from `fastly:env`. Pass the reader straight to `useSource` and envapt reads each key from it on the first access, caching the result. Add the `fastly` condition to your bundler's resolve conditions so bare `envapt` resolves the portable build.
```ts
import { env } from 'fastly:env';
import { Envapter, Converters } from 'envapt';
Envapter.useSource(env);
const origin = Envapter.getRequired('ORIGIN_URL', Converters.Url);
```
Custom variables are inlined into the Wasm binary at build time through the `--env` flag, so changing one needs a redeploy. See Fastly's [environment variables](https://www.fastly.com/documentation/reference/compute/ecp-env/) docs.
## Expo and React Native [#expo-and-react-native]
Put your config under `extra` in `app.config.js`. It is a real object at runtime through `expo-constants`, so you bind it whole and envapt does the rest.
```ts
import Constants from 'expo-constants';
import { Envapter, PortableSource } from 'envapt';
Envapter.useSource(new PortableSource(Constants.expoConfig?.extra ?? {}));
```
You cannot pass `process.env` here. On Metro it is not a runtime object, the bundler replaces each static `process.env.EXPO_PUBLIC_*` reference with its literal string at build time, so spreading it gives you nothing. If your config already lives in `EXPO_PUBLIC_*` variables, reference each by name.
```ts
import { Envapter, PortableSource, Converters } from 'envapt';
Envapter.useSource(
new PortableSource({
API_URL: process.env.EXPO_PUBLIC_API_URL,
FEATURE_BETA: process.env.EXPO_PUBLIC_FEATURE_BETA
})
);
const api = Envapter.getRequired('API_URL', Converters.Url);
```
Both Expo surfaces ship in your app bundle. Pass public configuration only, never a secret or a server-side key.
See Expo's [environment variables](https://docs.expo.dev/guides/environment-variables/) docs for the prefix rules and inlining.
## No filesystem [#no-filesystem]
There is no `.env` cascade on these runtimes. Bind a source before your first read, else an unbound read throws [`NoSourceBound`](/docs/errors). `PortableSource` snapshots its object at construction, so mutating that object afterward does not change what envapt reads. The default `@Envapt` accessor decorators work on all three, the legacy `envapt/legacy` form needs a compile step. For the providers and the contract see [Sources](/docs/sources).
# .env File Syntax (/docs/env-file-syntax)
envapt ships its own `.env` parser with zero dependencies. This page is the grammar it accepts. For which files load, see [Configuration](/docs/configuration); for `${VAR}` interpolation, see [Templates](/docs/templates).
## Keys and values [#keys-and-values]
Each line is `KEY=value`. An optional `export` prefix is allowed, so a file you also `source` in a shell works unchanged.
```dotenv
APP_NAME=envapt
export PORT=3000
```
A key is letters, digits, and underscores, and does not start with a digit. Leading whitespace around the key and `=` is ignored. An empty value resolves to an empty string.
```dotenv
EMPTY=
```
## Comments [#comments]
A line whose first non-whitespace character is `#` is a comment. An inline `#` after an unquoted value also starts a comment, but only when preceded by whitespace, so a `#` inside a value is kept.
```dotenv
# full-line comment
HOST=localhost # inline comment, stripped
COLOR=#ff0000 # kept: the # has no leading space before it
```
`COLOR` reads as `#ff0000`.
## Quoting [#quoting]
A value can be wrapped in single quotes, double quotes, or backticks. The quote style decides escape handling, not whether multi-line works (all three support it).
{/* prettier-ignore-start */}
| Quote | Escapes | Use it for |
| ---------------- | -------------------------------- | --------------------------------------------------- |
| `"double"` | decodes `\n` `\r` `\t` `\\` `\"` | values where you want `\n` to become a real newline |
| `'single'` | none, fully literal | values that should stay exactly as written |
| `` `backtick` `` | none, fully literal | same as single, when the value contains `'` |
{/* prettier-ignore-end */}
Only double quotes interpret escapes. Inside single quotes or backticks, `\n` stays as the two characters
backslash-n, it does not become a newline. Use double quotes when you want escape decoding.
## Multi-line values [#multi-line-values]
A quoted value runs across as many lines as you need: one opening quote, the content, one closing quote. This is how you store a certificate or private key.
```dotenv
TLS_CERT="-----BEGIN CERTIFICATE-----
MIIBkTCB+wIJAKHH...
...more base64...
-----END CERTIFICATE-----"
```
You read it back as one string, newlines intact:
```ts twoslash
import { Envapter } from 'envapt';
// ---cut---
const cert = Envapter.get('TLS_CERT');
// ^?
```
With double quotes you can also keep it on one line and write `\n` between segments; envapt decodes them. With single quotes or backticks the line breaks must be real (a literal `\n` stays literal).
## Unescaped inner quotes [#unescaped-inner-quotes]
Like dotenv, envapt matches a quoted value to the rightmost matching quote on the value, so unescaped inner quotes are tolerated.
```dotenv
JSON="{"name":"envapt","port":3000}"
```
`JSON` above round-trips to `{"name":"envapt","port":3000}` without escaping the inner quotes. Pair it with the
[`Json` converter](/docs/converters#json) to parse it.
The parser stores values literally; it does not expand `${VAR}` here. That interpolation runs when you read a value,
covered in [Templates](/docs/templates).
# Envapter (/docs/envapter)
`Envapter` is the functional entry point to envapt. Every reader comes two ways: a **static** method for one-off reads, and an **instance** method for when you hold a `new Envapter()` or extend it. Both read from the same parsed cache.
```ts twoslash
import { Envapter } from 'envapt';
// static, for a one-off read
const maxRetries = Envapter.getNumber('MAX_RETRIES', 3);
// ^?
// instance, when you hold a reference
const env = new Envapter();
const apiUrl = env.get('API_URL', 'http://localhost');
// ^?
```
## Primitives [#primitives]
Five readers cover the primitive types. Each parses the raw string and returns the matching type.
```ts twoslash
import { Envapter } from 'envapt';
// ---cut---
const name = Envapter.get('APP_NAME', 'envapt'); // string
const maxRetries = Envapter.getNumber('MAX_RETRIES', 3); // number
const debug = Envapter.getBoolean('DEBUG', false); // boolean
const maxBytes = Envapter.getBigInt('MAX_UPLOAD_BYTES', 0n); // bigint
const key = Envapter.getSymbol('KEY', Symbol.for('envapt:key')); // symbol
```
`getBoolean` reads `1`, `yes`, `true`, `on` as **true** and `0`, `no`, `false`, `off` as **false**, case-insensitively. Anything else uses the fallback.
## Fallbacks shape the return type [#fallbacks-shape-the-return-type]
A fallback supplies the default value and removes `undefined` from the return type. With a fallback the return is `T`; without one it's `T | undefined`, so the type makes you handle the missing case.
```ts twoslash
import { Envapter } from 'envapt';
// ---cut---
const withFallback = Envapter.get('API_URL', 'http://localhost');
// ^?
const noFallback = Envapter.get('API_URL');
// ^?
```
## Ordered keys [#ordered-keys]
Any reader accepts an ordered list of keys instead of a single name. The first key with a present value wins, so an aliased or renamed variable falls back to its old name in one read. An empty value, or a whitespace-only value under strict, is skipped like an unset key.
```ts twoslash
import { Envapter } from 'envapt';
// ---cut---
// READONLY_URL if set, else DATABASE_URL, else the fallback
const db = Envapter.get(['READONLY_URL', 'DATABASE_URL'], 'sqlite://memory');
// ^?
```
Pass the array inline. A pre-declared `const keys = [...]` widens to `string[]`, which does not satisfy the
ordered-key tuple type.
## Raw values [#raw-values]
`getRaw` returns the string exactly as loaded, with no type conversion. It exists only as an **instance** method:
```ts twoslash
import { Envapter } from 'envapt';
// ---cut---
const raw = new Envapter().getRaw('DATABASE_URL');
// ^?
```
## Converters [#converters]
For shapes beyond primitives, two readers take a converter:
* `getUsing(key, token, fallback?)` for built-in converter tokens (arrays, URLs, dates, durations, and more).
* `getWith(key, fn, fallback?)` for your own `(raw, fallback) => T` function.
```ts twoslash
import { Envapter, Converters } from 'envapt';
// ---cut---
const origins = Envapter.getUsing('CORS', Converters.array({ of: Converters.String }));
// ^?
const retries = Envapter.getWith('RETRIES', (raw, fallback) => (raw ? Number(raw) : fallback), 3);
// ^?
```
Fallbacks behave like the primitives, removing `undefined` from the return type. The [Converters](/docs/converters) page covers every built-in token.
## Template strings [#template-strings]
`resolve` is a tagged template that inlines env values by key, and expands any `${VAR}` references inside the resolved values.
```ts twoslash
import { Envapter } from 'envapt';
// ---cut---
// PGUSER=app, PGPASSWORD=secret, PGHOST=db.internal, PGDATABASE=myapp
const dsn = Envapter.resolve`postgres://${'PGUSER'}:${'PGPASSWORD'}@${'PGHOST'}/${'PGDATABASE'}`;
// ^?
```
See [Templates](/docs/templates) for `${VAR}` interpolation and cycle protection.
## Fail-fast on missing values [#fail-fast-on-missing-values]
`require` asserts that one or more keys are present and non-empty after template resolution, throwing an `EnvaptError` that names every missing key. A whitespace-only value counts as missing only under strict mode.
```ts twoslash
import { Envapter } from 'envapt';
// ---cut---
// Assuring all three keys are present at the start of the program, for example
Envapter.require('DATABASE_URL', 'API_KEY', 'SENTRY_DSN');
```
`has` returns `boolean` with the same missing semantics as `getRequired`. An ordered key list falls through to the first present key. It returns `true` exactly when a required read of the same key would find a value.
```ts twoslash
import { Envapter } from 'envapt';
// ---cut---
if (!Envapter.has('SENTRY_DSN')) {
throw new Error('Set SENTRY_DSN in your .env before deploying.');
}
```
For a typed fail-fast that returns the parsed value, use `getRequired`:
```ts twoslash
import { Envapter, Converters } from 'envapt';
// ---cut---
const url = Envapter.getRequired('DATABASE_URL', Converters.Url);
// ^?
```
`getRequiredAll` reads a whole group in one call and returns a typed record, throwing once with every missing key. A spec value is a token, an `array()` token, or a custom parser, and an optional casing (`'camelCase'`, `'PascalCase'`, or `'kebab-case'`) renames the keys. Casing splits on underscores, so it expects the conventional `SCREAMING_SNAKE` env-var names:
```ts twoslash
import { Envapter, Converters } from 'envapt';
// ---cut---
const config = Envapter.getRequiredAll(
{
DATABASE_URL: Converters.Url,
PORT: Converters.Port,
FEATURE_FLAGS: (raw) => new Set(raw.split(','))
},
'camelCase'
);
config;
// ^?
```
They throw on a missing value instead of returning a fallback. See [Strict mode](/docs/strict-mode) for the global switch and how required reads relate to it.
`require`, `getRequired`, and `getRequiredAll` throw `EnvaptError` for a missing or empty key (or, under strict mode, a whitespace-only one). `require` and `getRequiredAll` collect every missing key into one error.
Best to call `require` and `getRequiredAll` at startup, not lazily inside a request path, so a missing value fails the boot rather than a live request.
## Static vs instance [#static-vs-instance]
Statics suit scattered, one-off reads. Create an instance to carry an env reader as a dependency, or extend `Envapter` to group related config on one class.
```ts twoslash
import { Envapter } from 'envapt';
// ---cut---
class Config extends Envapter {
readonly maxUploadMb = this.getNumber('MAX_UPLOAD_MB', 25);
readonly debug = this.getBoolean('DEBUG', false);
}
```
Static and instance reads hit the same cache.
## Environment detection [#environment-detection]
`Envapter` also detects the current runtime environment and exposes it through the `isProduction` / `isStaging` / `isDevelopment` / `isTest` helpers. On Node, Bun, and Deno it auto-loads the matching `.env` files for that environment. The helpers, the `Environment` enum, and per-environment file selection are documented on [Environment](/docs/environment).
# Environment (/docs/environment)
envapt detects which environment it's running in and exposes it through the `isProduction` / `isStaging` / `isDevelopment` / `isTest` helpers, which work on every runtime. On Node, Bun, and Deno it also loads the matching `.env` files for that environment.
## Detecting the environment [#detecting-the-environment]
The environment is resolved from the first of these keys with a non-missing value, falling back to development:
`ENVIRONMENT` → `ENV` → `NODE_ENV` → `MODE` → `development`
An empty value is skipped like an unset key. A whitespace-only value is skipped only under strict mode.
`MODE` is last because Vite-family browser builds expose `import.meta.env.MODE` but none of the others, so binding `new PortableSource(import.meta.env)` lets the same helpers work there. See [Browser](/docs/browser).
Read it as an enum, or through boolean helpers for the common checks.
```ts twoslash
import { Envapter } from 'envapt';
// ---cut---
Envapter.environment; // Environment
// ^?
Envapter.isProduction; // boolean
Envapter.isStaging;
Envapter.isDevelopment;
Envapter.isTest;
```
`Environment` has four members: `Development`, `Staging`, `Production`, and `Test`. Matching is case-insensitive:
`production`, `staging`, `test`, and `development` map to their member. Any other value, or no key set at all, falls
back to `Development`; set `Envapter.debug = 'warn'` to log that fallback.
## Branching on the environment [#branching-on-the-environment]
Use the helpers where behavior should differ per environment.
```ts twoslash
import { Envapter } from 'envapt';
// ---cut---
const logLevel = Envapter.isProduction ? 'warn' : 'debug';
// ^?
if (Envapter.isDevelopment) {
// wire up dev-only tooling
}
```
## Overriding detection [#overriding-detection]
Set `Envapter.environment` to pin the environment explicitly, regardless of what the env vars say. It accepts the `Environment` enum or a string.
```ts twoslash
import { Envapter, Environment } from 'envapt';
// ---cut---
Envapter.environment = Environment.Staging;
Envapter.environment = 'production'; // strings work too
```
An explicit assignment also selects which `.env` files load on the next read, so set it before reading environment-specific values.
Pin `Envapter.environment` once, early in your entry file, before any config reads. Setting it after a read means
that read already used the previously detected environment's files.
## Per-environment `.env` files [#per-environment-env-files]
The cascade reads files from disk, so it runs on Node, Bun, and Deno only. On Cloudflare Workers and in the browser
there is no filesystem and no cascade; you bind an environment source instead. See [Sources](/docs/sources).
For the detected (or overridden) environment (production, staging, development), envapt layers a dotenv-flow cascade, loading the first file that exists at each level, highest precedence first:
1. `.env.${env}.local`
2. `.env.${env}`
3. `.env.local`
4. `.env`
So in production it reads `.env.production.local` → `.env.production` → `.env.local` → `.env`. Values from higher-precedence files win; missing files are skipped.
Precedence is **most-specific-wins**: `.env.production` outranks `.env.local`. This differs from dotenv-flow /
Next.js, where `.env.local` sits on top. The intent is that a committed `.env.production` stays authoritative for
that environment even if a stray `.env.local` is present.
## Custom profiles [#custom-profiles]
When your per-environment files live elsewhere, `configureProfiles` layers explicit paths on top of the cascade for the environments you name. Earlier paths in the array win. On the portable build these no-op by default, see [Compatibility](/docs/compatibility).
```ts twoslash
import { Envapter, Environment } from 'envapt';
// ---cut---
Envapter.configureProfiles({
[Environment.Production]: { paths: ['config/prod.env', 'secrets/prod.env'] },
[Environment.Staging]: { paths: 'config/staging.env' }
});
```
A path you name in `configureProfiles` must exist. A missing configured path throws `EnvaptError` with code
`EnvFilesNotFound` on the next read. The auto-cascade files are skipped when missing, but configured paths are not.
Set `useDefaults: false` to load **only** the configured paths and skip the auto-cascade entirely.
```ts twoslash
import { Envapter, Environment } from 'envapt';
// ---cut---
Envapter.configureProfiles({
[Environment.Production]: { paths: 'config/prod.env' },
useDefaults: false
});
Envapter.resetProfiles(); // back to the pure cascade
```
To bypass environment-based selection altogether and load a fixed set of files, set `Envapter.envPaths` directly, covered in [Configuration](/docs/configuration).
# Errors (/docs/errors)
When envapt rejects a value or a configuration, it throws an `EnvaptError`. The error carries a numeric `code` from the `EnvaptErrorCodes` enum, so you can branch on the exact cause.
```ts twoslash
import { Envapter, EnvaptError, EnvaptErrorCodes } from 'envapt';
// ---cut---
try {
Envapter.require('DATABASE_URL');
} catch (error) {
if (error instanceof EnvaptError) {
error.code;
// ^?
if (error.code === EnvaptErrorCodes.SchemaValidationFailed) {
error.issues?.forEach((issue) => console.error(issue.message));
}
}
}
```
## The error shape [#the-error-shape]
`EnvaptError` extends `Error` with:
* `code`: the `EnvaptErrorCodes` member.
* `name`: set to `` `EnvaptError []` `` (e.g. `EnvaptError [305]`).
* `message`: a human-readable description.
* `issues`: the validator's issue list, populated **only** for `SchemaValidationFailed` (208); `undefined` for every other code.
* `cause`: the original error when one is chained, used by `SchemaThrew` (209).
## Codes [#codes]
### Fallback (101-105) [#fallback-101-105]
{/* prettier-ignore-start */}
| Code | Name | Thrown when |
| ---- | ---------------------------------- | --------------------------------------------------------------------------------------------------------- |
| 101 | `InvalidFallback` | A fallback value is invalid for the converter (for example, a non-array fallback for an array converter). |
| 102 | `InvalidFallbackType` | The fallback value's type doesn't match the converter's return type. |
| 103 | `ArrayFallbackElementTypeMismatch` | An array fallback contains an element of the wrong type. |
| 104 | `FallbackConverterTypeMismatch` | The fallback is not a valid value for the converter (wrong type, out of range, or malformed). |
| 105 | `MalformedTimeFallback` | A time-string fallback isn't `` (for example `90m`). |
{/* prettier-ignore-end */}
### Converter (201-209) [#converter-201-209]
{/* prettier-ignore-start */}
| Code | Name | Thrown when |
| ---- | ------------------------------ | ---------------------------------------------------------------------------------------------- |
| 201 | `InvalidArrayConverterType` | The `Converters.array` configuration is malformed. |
| 202 | `InvalidBuiltInConverter` | An unknown built-in converter token is used. |
| 203 | `InvalidCustomConverter` | A custom converter is not a function. |
| 204 | `InvalidConverterType` | The converter type is not recognized. |
| 205 | `PrimitiveCoercionFailed` | Coercing a fallback through a primitive constructor fails. |
| 206 | `ArrayElementConversionFailed` | An array element fails to convert to the configured element type. |
| 207 | `EmptyArrayElement` | Strict mode only: an array element is empty or whitespace. |
| 208 | `SchemaValidationFailed` | A Standard Schema returns issues for a non-empty value. The only code that populates `issues`. |
| 209 | `SchemaThrew` | A schema's `validate` itself throws. The original error is on `cause`. |
{/* prettier-ignore-end */}
### Configuration and lookup (301-308) [#configuration-and-lookup-301-308]
{/* prettier-ignore-start */}
| Code | Name | Thrown when |
| ---- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 301 | `MissingDelimiter` | An array converter is missing its delimiter. |
| 302 | `InvalidUserDefinedConfig` | A bad decorator/options combination: `required` with `fallback`, `schema` with `converter`, a non-`StandardSchemaV1` `schema`, an async schema, or an invalid debug level. |
| 303 | `EnvFilesNotFound` | A configured `.env` path (via `envPaths` or `configureProfiles`) doesn't exist. |
| 304 | `InvalidKeyInput` | No key, a non-string key, or an empty-string key was passed. |
| 305 | `MissingEnvValue` | A required value is missing or empty, or whitespace-only under strict: `require`, `{ required: true }`, a schema with no fallback, or an unresolved template under strict mode. |
| 306 | `FileApiUnsupported` | On the portable build: a file-only API (`envPaths`, `baseDir`, `envFileOptions`, `configureProfiles`, `resetProfiles`) is called and `Envapter.fileApiMode = 'throw'` (the default warns once and no-ops). On the node build: a non-filesystem source is bound and a file API is called. See [Sources](/docs/sources). |
| 307 | `NoSourceBound` | A value is read before a source is bound with `Envapter.useSource`. Cannot occur on Node, where the source is bound on import. |
| 308 | `InvalidMergedSource` | `merge` is called with no members, or with more than one filesystem-backed source. |
{/* prettier-ignore-end */}
`issues` is set only for `SchemaValidationFailed` (208), so you can read `error.issues` after checking `error.code === EnvaptErrorCodes.SchemaValidationFailed` without a cast. See [Standard Schema](/docs/standard-schema) for where
208 and 209 come from.
# Introduction (/docs)
envapt reads config as typed values from any source you bind, `process.env`, a Cloudflare Workers binding, a browser bundle, a secret store, or any object. You give it a key and a fallback, and it returns a `number`, `boolean`, `URL`, `Date`, an array, or any type you define, instead of the `string | undefined` you get raw.
```ts twoslash
import { Envapter, Converters } from 'envapt';
// ---cut---
const redisUrl = Envapter.getUsing('REDIS_URL', Converters.Url, new URL('redis://localhost:6379'));
// ^?
```
## Two ways to read [#two-ways-to-read]
Read values functionally with [`Envapter`](/docs/envapter), or bind them to class fields with the [`@Envapt`](/docs/decorators) decorator. Both share the same parsing, converters, and cache.
```ts twoslash
import { EnvUrl } from 'envapt';
// ---cut---
class Config {
@EnvUrl('REDIS_URL', new URL('redis://localhost:6379'))
static accessor redisUrl: URL;
}
```
## What you get [#what-you-get]
* **Typed values.** A fallback removes `undefined` from the return type. [Converters](/docs/converters) convert numbers, booleans, bigint, JSON, URLs, dates, durations, and arrays, or pass your own function or a [Standard Schema](/docs/standard-schema) validator (zod, valibot, arktype).
* **Any source.** A [source](/docs/sources) is any object with a `readVars()` method. Bind `process.env`, a Cloudflare Workers binding, a browser bundle, or a [secret store](/docs/secret-stores) payload you fetched at boot. On Node, Bun, and Deno one binds on import.
* **Zero runtime dependencies.** The reader, converters, and the built-in `.env` parser are self-contained, so nothing is added to your dependency tree.
* **`.env` loading built in on Node.** The default Node source adds a per-environment file [cascade](/docs/environment), `${VAR}` [templates](/docs/templates), and [strict](/docs/strict-mode) / required checks. It needs a filesystem, so off Node you pass values through another source.
* **The same API on Node, Bun, Deno, Cloudflare Workers, and the browser.** See [Compatibility](/docs/compatibility).
## Next [#next]
Start with the [Quick Start](/docs/quick-start), then the [Envapter](/docs/envapter) reader API.
# Migration (v4 to v5) (/docs/migration-v4-to-v5)
v5 is a major release. This page lists what changed from v4 and how to update. Most apps only need the renames in the first few sections; read [Behavior changes to watch](#behavior-changes-to-watch) for shifts that compile cleanly but run differently.
## Array converters [#array-converters]
`Converters.Array` and the inline `{ delimiter, type }` object are replaced by the `Converters.array(...)` builder.
```ts
// v4
@Envapt('PORTS', { converter: { delimiter: ',', type: Converters.Number }, fallback: [] })
// v5
@Envapt('PORTS', { converter: Converters.array({ of: Converters.Number }), fallback: [] })
```
`of` defaults to `string` and `delimiter` to `,`, and `of` also accepts a `(raw) => T` function for a typed element. `Converters` is now a plain object rather than an enum, so it is no longer usable as a type; use `ConverterToken` instead. See [Converters](/docs/converters).
## dotenvConfig is now envFileOptions [#dotenvconfig-is-now-envfileoptions]
`Envapter.dotenvConfig` is renamed to `Envapter.envFileOptions` (the type `DotenvConfigOptions` becomes `EnvFileOptions`). Only `encoding` and `override` remain. `quiet`, `DOTENV_KEY`, `debug`, `path`, and `processEnv` are gone.
```ts
// v4
Envapter.dotenvConfig = { override: true, quiet: false, debug: true };
// v5
Envapter.envFileOptions = { override: true };
```
To choose which files load, use [`Envapter.envPaths`](/docs/configuration). `DOTENV_KEY` (dotenv-vault) is out of scope; decrypt externally and pass the resulting `.env` path to `envPaths`.
## Debug output [#debug-output]
The `debug` option moved to its own switch, and output is now on stderr with an `[envapt]` prefix instead of `[dotenv]`.
```ts
// v4
Envapter.dotenvConfig = { debug: true };
// v5
Envapter.debug = 'verbose'; // or set ENVAPT_DEBUG=verbose
```
Levels are `silent` (default), `warn`, and `verbose`. See [Configuration](/docs/configuration#debug-logging).
## Validation with Standard Schema [#validation-with-standard-schema]
Custom converters that throw still work, but the recommended way to validate is the [Standard Schema](/docs/standard-schema) adapter (zod, valibot, arktype, or a hand-rolled validator).
```ts
// v4: a custom converter that throws
@Envapt('PORT', {
converter: (raw) => {
const n = Number(raw);
if (Number.isNaN(n)) throw new Error('bad PORT');
return n;
}
})
// v5
@Envapt('PORT', { schema: z.coerce.number().int().min(1024) })
```
## Decorators [#decorators]
The classic positional form is deprecated in v5 and removed in v6. Use the options object. See the [v5 to v6 migration](/docs/migration-v5-to-v6).
```ts
// v4 (removed in v6)
@Envapt('PORT', 8080, Number)
// v5
@Envapt('PORT', { converter: Converters.Number, fallback: 8080 })
```
Decorated fields need the right declaration form. Static fields use a plain `static readonly`, instance fields use `declare readonly`, both with no initializer. v7 later replaced this with the TC39 accessor form, see [Migration (v6 to v7)](/docs/migration-v6-to-v7) and [Decorators](/docs/decorators#declaring-decorated-fields).
## Node version [#node-version]
The Node floor dropped from `>=24` to `>=20`, since the old pin existed only for an API envapt no longer uses. Bun `>=1.3` and Deno `>=2.5` are unchanged.
## Behavior changes to watch [#behavior-changes-to-watch]
These changes compile without edits but run differently than v4. Check them before upgrading.
* **Per-environment files auto-load.** When `envPaths` is not set, v5 layers a cascade, `.env.${env}.local`, `.env.${env}`, `.env.local`, then `.env`. A stray `.env.local` or `.env.production` in your working directory now loads where v4 ignored it. See [Environment](/docs/environment).
* **Array element failures throw.** A value that fails its element converter throws `EnvaptError` with code `ArrayElementConversionFailed` (206) instead of silently producing a malformed array.
* **Time-string fallbacks coerce.** `Envapter.getUsing('X', Converters.Time, '10s')` now returns `10000`, not the string `'10s'`. A malformed time-string fallback throws `MalformedTimeFallback` (105) rather than the older generic code.
`process.env` is still not mutated by default; it never was in v4. If you relied on loaded keys appearing in `process.env`, opt in with [`Envapter.syncProcessEnv`](/docs/configuration#mirroring-to-processenv). See [Errors](/docs/errors) for the full code table.
# Migration (v5 to v6) (/docs/migration-v5-to-v6)
v6 is a major release. This page lists what changed from v5 and how to update. The code change is one removed decorator form. Read [Behavior changes to watch](#behavior-changes-to-watch) for a runtime shift that compiles cleanly but runs differently.
Upgrading further? v7 makes the decorators TC39 accessor decorators by default. See [Migration (v6 to
v7)](/docs/migration-v6-to-v7).
## Decorators [#decorators]
The positional `@Envapt('KEY', fallback, converter)` form, deprecated in v5, is removed. Use the options object, or a [sugar decorator](/docs/decorators) for the common case. The no-argument `@Envapt('KEY')` form is unchanged.
```ts
// v5 (removed in v6)
@Envapt('PORT', 8080, Number)
// v6 options object
@Envapt('PORT', { converter: Converters.Number, fallback: 8080 })
// v6 sugar decorator, shorter than the old positional form
@EnvNum('PORT', 8080)
```
The sugar decorators `@EnvNum`, `@EnvStr`, `@EnvBool`, `@EnvUrl`, and `@EnvTime` each wrap a built-in converter, so the terse common case stays shorter than the positional form ever was. A second argument that is not an options object now throws `EnvaptError` with `InvalidUserDefinedConfig` at decoration time, so a stray positional call surfaces immediately instead of being misread. The internal `InferPrimitiveFallbackType` type is removed with the overload.
## Behavior changes to watch [#behavior-changes-to-watch]
This change compiles without edits but runs differently than v5. Check it before upgrading.
* **`NODE_ENV=test` resolves to `Environment.Test`.** `Envapter.isTest` returns true under a test runner, and `isDevelopment` returns false where it returned true on v5.1 and earlier. Vite's `MODE=test` maps the same way. This first shipped by mistake in the 5.2.0 minor, which is deprecated on npm. If you branch on `isDevelopment` inside tests, switch to `isTest` or set the environment explicitly. See [Environment](/docs/environment).
# Migration (v6 to v7) (/docs/migration-v6-to-v7)
v7 is a major release. The decorator surface changed. Everything else (the `Envapter` readers, converters, sources, and types) is unchanged and stays on `envapt`.
## Decorators are now TC39 accessor decorators [#decorators-are-now-tc39-accessor-decorators]
The default `@Envapt` import, and the sugar decorators, is now a TC39 Stage 3 accessor decorator. It needs no `experimentalDecorators` flag and runs on any runtime, including Bun and Deno executing a `.ts` file directly.
Declare the field with the `accessor` keyword. Static fields use `static accessor x: T`, instance fields use `accessor x!: T` with the definite-assignment `!`.
```ts
// v6 (legacy form)
import { Envapt, Converters } from 'envapt';
class Config {
@Envapt('PORT', { converter: Converters.Number, fallback: 3000 })
static readonly port: number;
}
// v7 default (accessor form, no experimentalDecorators)
import { Envapt, Converters } from 'envapt';
class Config {
@Envapt('PORT', { converter: Converters.Number, fallback: 3000 })
static accessor port: number;
}
```
For an instance field, the change is from `declare readonly x: T` to `accessor x!: T`.
## Keeping the legacy decorators [#keeping-the-legacy-decorators]
If you cannot switch to the accessor form yet, the v6 decorators move to the `envapt/legacy` subpath. They keep the `static readonly` / `declare readonly` form and still need `experimentalDecorators`.
```ts
// keep the v6 behavior
import { Envapt, Converters } from 'envapt/legacy';
class Config {
@Envapt('PORT', { converter: Converters.Number, fallback: 3000 })
static readonly port: number;
}
```
Only the decorators move. `Envapter`, `Converters`, the sources, and the types stay on `envapt`. A module that imports decorators only from `envapt/legacy` must also `import 'envapt'` once so the runtime source binds.
## Decorated properties are read-only [#decorated-properties-are-read-only]
Assigning to a decorated property now throws `EnvaptError`. Till v6 the legacy decorator installed only a getter, so an assignment was a silent no-op in sloppy mode or a native `TypeError` in strict mode. Both the default and legacy forms throw the clearer error in v7.
## Decorated fields are typechecked [#decorated-fields-are-typechecked]
The decorator checks the decorated field's declared type against the converter's output. A field that cannot hold the value is a compile error, `[envapt] field type must hold the converter output`. A no-fallback read can be `null`, so a field that compiled as `static readonly port: number` for `@EnvNum('PORT')` on v6 now needs `number | null` (or `static accessor port: number | null` in the modern form). This check applies to both the modern and legacy decorators.
# Migration (v7 to v8) (/docs/migration-v7-to-v8)
v8 is a major release with **eleven** breaking changes. The source classes are renamed, the `envapt/workerd` and `envapt/browser` subpaths are removed, the portable build's filesystem-only config APIs warn once and no-op by default, the public type surface is trimmed, the functional required-read bag moves to `getRequired`, the `Integer` and `Float` converters reject malformed values, the missing-value rule is unified so ordered reads skip an empty key and whitespace-only values follow `strict`, built-in converter fallbacks are validated by value, a missing read with no fallback resolves to `undefined` across every reader, `getWith` passes the raw value to its custom converter, and an explicit `undefined` fallback counts as no fallback.
Most upgrades touch only two of these, the source renames and the single `envapt` import. The rest apply only if you imported an internal type by name, typed a decorator field as `| null`, or leaned on loose parsing, and each section says when it affects you.
## Source and type renames [#source-and-type-renames]
The source class names drop the `Env` infix. `PortableSource` is the one source for every runtime without a filesystem, and it replaces both `ManualEnvSource` and `WorkerEnvSource`, which were the same class. `FileSource` replaces `NodeEnvSource`, and the `Source` type replaces `EnvSource`.
{/* prettier-ignore-start */}
| v7 | v8 |
| ------------------------------------ | ---------------- |
| `ManualEnvSource`, `WorkerEnvSource` | `PortableSource` |
| `NodeEnvSource` | `FileSource` |
| `EnvSource` (type) | `Source` |
{/* prettier-ignore-end */}
```ts
// v7
import { Envapter, ManualEnvSource } from 'envapt';
Envapter.useSource(new ManualEnvSource(config));
// v8
import { Envapter, PortableSource } from 'envapt';
Envapter.useSource(new PortableSource(config));
```
If you were already on v7.1, `PortableSource` and `Source` existed and `ManualEnvSource` / `WorkerEnvSource` / `EnvSource` were deprecated aliases, so v8 only removes the aliases. `NodeEnvSource` is renamed to `FileSource` in v8.
## One universal `envapt` import [#one-universal-envapt-import]
The `envapt/workerd` and `envapt/browser` subpaths are gone. Import from `envapt` everywhere. The package export conditions resolve the portable build on Workers, edge runtimes, the browser, and react-native, and the node build on Node, Bun, and Deno.
```ts
// v7
import { Envapter, WorkerEnvSource } from 'envapt/workerd';
import { Envapter, ManualEnvSource } from 'envapt/browser';
// v8
import { Envapter, PortableSource } from 'envapt';
```
The edge runtimes that used to fall through to the node build now resolve the portable build through the `edge-light`, `fastly`, and `worker` conditions. Vercel Edge and Workers apply these automatically. On Fastly Compute, add the `fastly` condition to your bundler's resolve conditions. `envapt/config` and `envapt/legacy` are unchanged.
## File-only APIs warn instead of throwing [#file-only-apis-warn-instead-of-throwing]
On the portable build, the filesystem-only config APIs (`envPaths`, `baseDir`, `envFileOptions`, `configureProfiles`, `resetProfiles`) used to throw `FileApiUnsupported`. They now warn once and no-op by default, so a config module that touches them can be written once and run on both Node and the portable runtimes. To restore the v7 behavior, set `Envapter.fileApiMode = 'throw'`.
```ts
// v7: calling a file API on the portable build threw FileApiUnsupported
// v8: it warns once and no-ops by default
Envapter.envPaths = ['.env']; // warns once, then does nothing on Workers
// opt back into throwing
Envapter.fileApiMode = 'throw';
Envapter.envPaths = ['.env']; // throws FileApiUnsupported again
```
The safety net is unchanged. An unconfigured read still throws `NoSourceBound` on the first access, whatever `fileApiMode` is, so a worker that forgot to bind a source fails fast rather than serving wrong config. And `fileApiMode` governs only the portable file-API behavior. On the node build, binding a non-filesystem source and then calling a file API still throws `FileApiUnsupported`.
## The portable types now include the file APIs [#the-portable-types-now-include-the-file-apis]
Before v8 the portable build's types omitted the five file APIs, so calling one was a compile error. v8 drops that fence. The portable types include them, so a config module shared between your Node dev setup and a Workers deploy type-checks the same in both places. At runtime you get the warn-once and no-op above, or a throw under `fileApiMode = 'throw'`.
## Required reads move to `getRequired` [#required-reads-move-to-getrequired]
The `{ required: true }` options-bag form of `getUsing` and `getWith` is removed. A required read is now `getRequired(key, converter)`, which takes the converter positionally, returns the non-undefined value, and throws `MissingEnvValue` when the key is missing or empty. New in v8, `getRequiredAll(spec)` reads a group of required values in one call and returns a typed record, throwing once with every missing key.
```ts
// v7
const url = Envapter.getUsing('DATABASE_URL', { converter: Converters.Url, required: true });
const upper = Envapter.getWith('NAME', { converter: (raw) => (raw ?? '').toUpperCase(), required: true });
// v8
const url = Envapter.getRequired('DATABASE_URL', Converters.Url);
const upper = Envapter.getRequired('NAME', (raw) => raw.toUpperCase());
// v8, several at once
const { DATABASE_URL: db, PORT: port } = Envapter.getRequiredAll({
DATABASE_URL: Converters.Url,
PORT: Converters.Number
});
```
`getUsing` and `getWith` keep their positional fallback forms unchanged. The `@Envapt` decorator's `{ required: true }` option stays, only the functional readers dropped the bag.
## Trimmed public type exports [#trimmed-public-type-exports]
v8 exports 15 types instead of 36. The internal machinery that leaked into v7's public surface is gone, the source shape interfaces (`BareEnvSource` / `FileEnvSource`), the decorator return types, the schema brands, the converter and inference helper types, `EnvKeyInput`, `ArrayOf` / `ArrayElement`, and the `InferSchemaInput` / `InferSchemaOutput` aliases. Inference on the readers and decorators is unchanged, since those types resolve at the call site.
You only need a change here if you imported one of these by name.
* A custom source typed against `BareEnvSource` / `FileEnvSource` uses the `Source` union instead.
* A schema output type from `InferSchemaOutput` uses your validator's own inference (`z.infer`, valibot's `InferOutput`, arktype's `.infer`) or `StandardSchemaV1.InferOutput`.
## Stricter `Integer` and `Float` converters [#stricter-integer-and-float-converters]
`Converters.Integer` and `Converters.Float` used to parse with `parseInt` and `parseFloat`, which read a leading number and ignored the rest. `42abc` became `42`, `3.9` became `3` under `Integer`, and a value past 2^53 lost precision. v8 parses both with `Number` and validates the result, so a value that is not a clean number falls back to the default, or throws under `getRequired`.
`Integer` requires `Number.isSafeInteger`, so `42abc`, `3.9`, and magnitudes past 2^53 all fall back. `Float` rejects trailing characters like `3.14xyz`. `Float` still accepts `Infinity`, matching the `number` converter and `getNumber`.
```ts
import { Converters, Envapter } from 'envapt';
// with PORT="8080abc"
Envapter.getRequired('PORT', Converters.Integer);
// v7: 8080 (parseInt read the leading number)
// v8: throws MissingEnvValue (Number rejects it)
```
You only need a change here if an env value was relying on the loose parse. A value that is already a clean integer or float reads the same.
## The missing-value rule is unified [#the-missing-value-rule-is-unified]
envapt uses one rule for when a value counts as missing, across ordered-key reads, `getRequired` / `getRequiredAll`, `Envapter.require`, the `@Envapt({ required: true })` decorator, environment detection, and templates. A value is missing when it is unset or an empty string, always, and additionally when it is whitespace-only under `Envapter.strict = true`. Two v7 behaviors change.
**Ordered reads skip a present-but-empty key.** An ordered read (`get`, `getNumber`, the decorators, or an array of keys) falls through a key set to an empty string, the same as an unset key. v7 stopped at the first present key even when its value was blank.
```ts
import { Envapter } from 'envapt';
// with PRIMARY="" and FALLBACK="value"
Envapter.get(['PRIMARY', 'FALLBACK'], 'default');
// v7: "default" (stopped at the empty PRIMARY)
// v8: "value" (falls through to FALLBACK)
```
**Whitespace-only values follow `strict`.** `Envapter.require` and the `@Envapt({ required: true })` decorator keep a whitespace-only value in the default non-strict mode, where v7 treated it as missing. Set `Envapter.strict = true` to treat it as missing again. Environment detection skips a whitespace-only key only under strict mode, where v7 used the key in every mode.
```ts
// with NAME=" " (whitespace only)
Envapter.require('NAME');
// v7: throws MissingEnvValue
// v8 (non-strict): passes
// v8 (strict): throws MissingEnvValue
```
`getRequired` and `getRequiredAll` are new in v8 and use this rule from the start, so nothing changes for them.
## Built-in converter fallbacks are validated by value [#built-in-converter-fallbacks-are-validated-by-value]
v7 checked only a fallback's type (a `number` for `Number`, a `Date` for `Date`). v8 also checks the value, so a fallback v7 accepted but the converter would reject now throws `FallbackConverterTypeMismatch`: a `NaN` `Number` or `Float` fallback, a non-safe-integer `Integer` fallback, or an `Invalid Date` fallback. The new `Port` and `Email` converters require a valid fallback too.
```ts
import { Converters, Envapter } from 'envapt';
// with SAMPLE_RATE unset
Envapter.getUsing('SAMPLE_RATE', Converters.Float, NaN);
// v7: returned NaN
// v8: throws FallbackConverterTypeMismatch
```
You only need a change here if you passed a sentinel or placeholder fallback that is not a real value. Pass a valid fallback, or omit it to get `T | undefined`.
## A missing read with no fallback resolves to `undefined` [#a-missing-read-with-no-fallback-resolves-to-undefined]
A read of a missing key with no fallback resolves to `undefined` everywhere. The functional readers (`get`, `getNumber`, `getUsing`, `getWith`, `getRaw`) already returned `undefined`. The decorators returned `null` on this path. v8 returns `undefined` from the decorators and the converter dispatch too, so one value marks an absent read across the whole API.
The no-fallback decorator field types drop `| null`. A field typed `T | null` stops type-checking. Retype it to `T | undefined`.
```ts
// v7
class Config {
@Envapt('OPTIONAL_TOKEN') static accessor token: string | null;
}
// Config.token is null when OPTIONAL_TOKEN is unset
// v8
class Config {
@Envapt('OPTIONAL_TOKEN') static accessor token: string | undefined;
}
// Config.token is undefined when OPTIONAL_TOKEN is unset
```
You only need a change here if you typed a no-fallback decorator field as `| null`, or branched on a decorator read with `=== null`. Nullish handling (`?? fallback`, `== null`) keeps working.
## `getWith` passes the raw value to your converter [#getwith-passes-the-raw-value-to-your-converter]
`getWith` calls your custom converter with the raw value on every read, including a missing key, where the converter receives `raw === undefined`. v7 short-circuited a missing key and returned the fallback without running the converter. The converter already received the fallback as the second argument, so you can still return it when `raw` is missing. Just that now it's your converter's choice, not envapt's.
```ts
const name = Envapter.getWith('NAME', (raw, fallback) => raw?.toUpperCase(), 'ANON');
// with NAME unset
// v7: 'ANON' (the converter never ran)
// v8: undefined (the converter ran with raw === undefined)
```
A converter that reads `raw` without a guard now runs on a missing key. Guard it with `raw ?? ''` or an early `if (!raw) return fallback`. The `ConverterFunction` type has always typed `raw` as `string | undefined` and passed the fallback as the second argument. The `@Envapt` custom-converter decorator already ran the converter on a missing key, and v8 brings `getWith` in line with it.
## An explicit `undefined` fallback counts as no fallback [#an-explicit-undefined-fallback-counts-as-no-fallback]
Passing `undefined` as the fallback argument counts as no fallback, across every reader and the `@Envapt` options. `Envapter.parse` previously read an explicit `undefined` as a provided fallback and returned it for a missing key. It now throws `MissingEnvValue`.
```ts
// with API_URL unset
Envapter.parse('API_URL', schema, undefined); // why would you even do this
// v7: undefined
// v8: throws MissingEnvValue
```
You only need a change here if you passed `undefined` to `parse` to make a key optional. Pass a real fallback, or guard the read with `getRaw` before parsing.
# Migrations (/docs/migrations)
Each guide covers one major-version jump and the changes to expect when you take it.
* **[v7 to v8](/docs/migration-v7-to-v8).** The source classes get shorter names, one universal `envapt` import replaces the `workerd` and `browser` subpaths, and the portable file APIs warn instead of throwing by default.
* **[v6 to v7](/docs/migration-v6-to-v7).** The default decorators become TC39 Stage 3 accessors, and the legacy decorators move to `envapt/legacy`.
* **[v5 to v6](/docs/migration-v5-to-v6).** The positional decorator form is removed, and the test-environment behavior changes.
* **[v4 to v5](/docs/migration-v4-to-v5).** Renames and behavior shifts to watch when moving off v4.
# Quick Start (/docs/quick-start)
## Install [#install]
pnpm
npm
yarn
bun
deno
```bash
pnpm add envapt
```
```bash
npm install envapt
```
```bash
yarn add envapt
```
```bash
bun add envapt
```
```bash
deno add jsr:@materwelon/envapt
```
## Read a value [#read-a-value]
Put a variable in your `.env`:
```dotenv
PORT=8080
```
Read it as a number, with a fallback for when it is unset:
```ts twoslash
import { Envapter, Converters } from 'envapt';
// ---cut---
const port = Envapter.getUsing('PORT', Converters.Port, 3000);
// ^?
```
The fallback supplies the default and removes `undefined` from the type. See [Envapter](/docs/envapter) for the full reader API and [Converters](/docs/converters) for non-primitive types.
## Or bind it to a class [#or-bind-it-to-a-class]
The [`@Envapt`](/docs/decorators) decorator binds a value to a typed field. The default form is a TC39 Stage 3 accessor decorator.
```ts twoslash
import { Envapt, Converters } from 'envapt';
// ---cut---
class Config {
@Envapt('PORT', { converter: Converters.Port, fallback: 3000 })
static accessor port: number;
}
```
Static fields use `static accessor x: T`, instance fields use `accessor x!: T`. If your project already runs on
`experimentalDecorators`, import the legacy form from `envapt/legacy` instead. See
[Decorators](/docs/decorators#declaring-decorated-fields).
## Read a whole config at startup [#read-a-whole-config-at-startup]
`getRequiredAll` reads a group in one call and returns a typed record. A missing key throws an error that specifies every key that was missing.
```ts twoslash
import { Envapter, Converters } from 'envapt';
// ---cut---
const config = Envapter.getRequiredAll(
{
PORT: Converters.Port,
DATABASE_URL: Converters.Url,
CACHE_TTL: Converters.Time,
ALLOWED_ORIGINS: Converters.array()
},
'PascalCase'
);
config;
// ^?
```
## Mirror loaded values to process.env [#mirror-loaded-values-to-processenv]
Import `envapt/config` to load the `.env` cascade and mirror every loaded key into `process.env` in one step, so `process.env.PORT` works without touching `Envapter`. This is the same pattern as the `dotenv/config` import, so code that reads `process.env` directly still sees the values.
```ts twoslash
import 'envapt/config';
const port = process.env.PORT; // the loaded keys are now on process.env
```
It is the side-effect form of `Envapter.syncProcessEnv = true` plus an eager load, and follows the same collision rules. `dotenv/config` loads a single `.env`. This loads the whole per-environment [cascade](/docs/environment).
Preload it without editing your entry file:
```bash
node --import envapt/config app.js # ESM
node -r envapt/config app.js # CommonJS
```
For typed reads on top, keep using [`Envapter`](/docs/envapter).
## Requirements [#requirements]
envapt runs on Node `>=20`, Bun `>=1.3.11`, Deno `>=2.5`, Cloudflare Workers, and the browser. On Node, Bun, and Deno a source binds on import. On Workers and in the browser you bind one yourself. The functional API needs no build step on any runtime. The default decorator API uses TC39 accessor decorators, which run on Bun and Deno executing a `.ts` file directly. The legacy decorators (`envapt/legacy`) need `experimentalDecorators`. See [Compatibility](/docs/compatibility).
# Using secret stores (/docs/secret-stores)
envapt does not fetch secrets, contact any remote API, or manage credentials. It reads typed values on top of whatever object is bound as the active [source](/docs/sources). To use a secret store, get your secrets into envapt one of two ways.
* **Wrapper CLIs** (`op run`, `doppler run`, `infisical run`, `bws run`) inject secrets as real environment variables before your process starts. On Node, envapt reads them from `process.env` with no extra code.
* **SDKs and HTTP APIs** hand you the secrets in your own code. You fetch them at boot, build a plain object, then bind it as a source.
A source's `readVars()` is synchronous, so finish the async fetch before you call `useSource`. Await the SDK call,
reduce the result to a `Record`, then bind that object.
## Wrapper CLIs, zero code on Node [#wrapper-clis-zero-code-on-node]
A wrapper CLI resolves your secrets and execs your process with them already set as environment variables. The default Node source reads them like any other variable, so the app code is unchanged.
```dotenv title="secrets.env"
# references, not the secrets themselves
DB_PASSWORD=op://prod/db/password
API_BASE_URL=op://prod/api/base-url
```
```bash
op run --env-file=secrets.env -- node dist/server.js
```
```ts twoslash
import { Envapter, Converters } from 'envapt';
// ---cut---
// op set these in process.env before the process started
const dbPassword = Envapter.get('DB_PASSWORD');
const apiBase = Envapter.getUsing('API_BASE_URL', Converters.Url);
```
The same shape works with `doppler run -- node dist/server.js`, `infisical run -- ...`, and `bws run -- ...`. Nothing binds a source, because the secrets are already in `process.env`.
## Fetch at boot, then bind [#fetch-at-boot-then-bind]
When you fetch with an SDK or HTTP call, await it, reduce the result to a flat object, then bind it. Reads after that are the normal typed API.
```ts twoslash
import { Envapter, Converters } from 'envapt';
// ---cut---
declare const secrets: Record; // already fetched and awaited
Envapter.useSource({ readVars: () => secrets });
const dbUrl = Envapter.getUsing('DATABASE_URL', Converters.Url);
const poolSize = Envapter.getNumber('DB_POOL_SIZE', 10);
```
On Node, `useSource` replaces the default Node source, so the `.env` cascade and the file-only config APIs
(`envPaths`, `baseDir`, `configureProfiles`) stop working after the swap. Bind a store source only if you do not
also need those. To keep both, use `merge` (below).
### Inline object or PortableSource [#inline-object-or-portablesource]
`PortableSource` runs `coerceToStringRecord` at construction, so non-string values (numbers, booleans, objects) are JSON-stringified before they reach envapt's cache. The bare `{ readVars: () => secrets }` form skips that step, so any non-string in the payload reaches the converters unparsed. Prefer `PortableSource` unless you know the payload is already all strings.
```ts twoslash
import { Envapter, PortableSource } from 'envapt';
// ---cut---
declare const secrets: Record;
Envapter.useSource(new PortableSource(secrets));
```
## Per-store examples [#per-store-examples]
Each example fetches a flat `Record` and binds it. Pin and verify each SDK against its own docs before shipping, the snippets reflect the API at the time of writing.
Each example reads one or two bootstrap credentials from `process.env` to initialize the SDK. These have to exist before envapt's source is bound, so they are the narrow exception to routing all config through envapt.
### AWS Secrets Manager [#aws-secrets-manager]
```ts
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
import { Envapter, PortableSource, Converters } from 'envapt';
// This should be done early in your app, before any envapt read
const client = new SecretsManagerClient();
const { SecretString } = await client.send(new GetSecretValueCommand({ SecretId: 'prod/myapp' }));
if (!SecretString) throw new Error('secret is binary, not a string');
const secrets = JSON.parse(SecretString) as Record;
Envapter.useSource(new PortableSource(secrets));
// Now use envapt anywhere in your project
const dbUrl = Envapter.getUsing('DATABASE_URL', Converters.Url);
```
Store the secret as one flat JSON object so `JSON.parse` returns a `Record`. The client reads credentials and region from the standard AWS credential chain.
### HashiCorp Vault [#hashicorp-vault]
```ts
import vault from 'node-vault';
import { Envapter, PortableSource, Converters } from 'envapt';
const vc = vault({ endpoint: process.env.VAULT_ADDR });
const { auth } = await vc.approleLogin({
role_id: process.env.VAULT_ROLE_ID,
secret_id: process.env.VAULT_SECRET_ID
});
vc.token = auth.client_token;
const { data } = await vc.read('secret/data/myapp');
const secrets = data.data as Record;
Envapter.useSource(new PortableSource(secrets));
const gcpServiceAccount = Envapter.getUsing('GCP_SA_JSON', Converters.Json);
```
HashiCorp ships no official Node client, `node-vault` is community-maintained. For KV v2 the payload sits at `data.data`, two levels deep.
### 1Password [#1password]
Use `op run` (above) to avoid an SDK dependency, or fetch with the SDK and a service-account token.
```ts
import sdk from '@1password/sdk';
import { Envapter, PortableSource } from 'envapt';
const op = await sdk.createClient({
auth: process.env.OP_SERVICE_ACCOUNT_TOKEN,
integrationName: 'my-app',
integrationVersion: 'v1.0.0'
});
const refs = {
DB_PASSWORD: 'op://prod/db/password',
API_KEY: 'op://prod/api/key'
};
const secrets: Record = {};
for (const [key, ref] of Object.entries(refs)) {
secrets[key] = await op.secrets.resolve(ref);
}
Envapter.useSource(new PortableSource(secrets));
const dbPassword = Envapter.get('DB_PASSWORD');
```
`@1password/sdk` is pre-1.0, pin the version. It needs a service-account token in `OP_SERVICE_ACCOUNT_TOKEN`.
### Doppler [#doppler]
Use `doppler run -- node dist/server.js` for the zero-code path, or download the secrets over HTTP.
```ts
import { Envapter, PortableSource, Converters } from 'envapt';
const url = `https://api.doppler.com/v3/configs/config/secrets/download?project=${project}&config=${config}&format=json`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.DOPPLER_TOKEN}` } });
const secrets = (await res.json()) as Record;
Envapter.useSource(new PortableSource(secrets));
const corsOrigins = Envapter.getUsing('ALLOWED_ORIGINS', Converters.array({ of: Converters.Url }));
```
Confirm the auth scheme against Doppler's API reference. Older docs used HTTP Basic with the token as the username and an empty password.
### Infisical [#infisical]
```ts
import { InfisicalSDK } from '@infisical/sdk';
import { Envapter, PortableSource, Converters } from 'envapt';
const client = new InfisicalSDK();
await client.auth().universalAuth.login({
clientId: process.env.INFISICAL_CLIENT_ID!,
clientSecret: process.env.INFISICAL_CLIENT_SECRET!
});
const { secrets } = await client.secrets().listSecrets({ projectId: 'YOUR_PROJECT_ID', environment: 'prod' });
const vars = Object.fromEntries(secrets.map((s) => [s.secretKey, s.secretValue]));
Envapter.useSource(new PortableSource(vars));
const cacheTtl = Envapter.getUsing('CACHE_TTL', Converters.Time, '15m');
```
The secret shape uses `secretKey` and `secretValue`. Use `@infisical/sdk` (v5+), not the unmaintained `infisical-node`.
### Bitwarden Secrets Manager [#bitwarden-secrets-manager]
```ts
import { BitwardenClient, DeviceType, LogLevel } from '@bitwarden/sdk-napi';
import { Envapter, PortableSource } from 'envapt';
const client = new BitwardenClient(
{ apiUrl: 'https://api.bitwarden.com', identityUrl: 'https://identity.bitwarden.com', deviceType: DeviceType.SDK },
LogLevel.Info
);
await client.auth().loginAccessToken(process.env.BW_ACCESS_TOKEN!);
const { data } = await client.secrets().list(process.env.BW_ORG_ID!);
const full = await client.secrets().getByIds(data.map((s) => s.id));
const vars = Object.fromEntries(full.data.map((s) => [s.key, s.value]));
Envapter.useSource(new PortableSource(vars));
const smtpPort = Envapter.getNumber('SMTP_PORT', 587);
```
`list()` returns identifiers only, fetch the values with `getByIds`. The package ships native binaries, so it runs on Node only. It is in beta.
## Keep the .env cascade with merge [#keep-the-env-cascade-with-merge]
To read fetched secrets AND the `.env` cascade, bind both with `merge` instead of replacing the Node source. It layers the secrets over `process.env` and the `.env` files in one bind, with no ordering trap and no write to `process.env`.
```ts twoslash
import { Envapter, FileSource, PortableSource, merge, Converters } from 'envapt';
// ---cut---
declare const secrets: Record; // fetched and awaited
Envapter.useSource(merge(new FileSource(), new PortableSource(secrets)));
const dbUrl = Envapter.getUsing('DATABASE_URL', Converters.Url);
```
The secrets win over `.env` and `process.env`, and `.env` backfills any key the secrets omit. See [Sources](/docs/sources).
# Sources (/docs/sources)
A source is the object envapt reads variables from. On Node, Bun, and Deno one is bound automatically on import, reading `process.env` and the `.env` file cascade. On Cloudflare Workers and in the browser you bind a source yourself with `Envapter.useSource(...)`, then read with the same typed API.
On Node, Bun, and Deno you never call `useSource`. The Node entry binds a source that reads a clone of `process.env`
plus the `.env` cascade at import time.
## The providers [#the-providers]
envapt ships two sources.
* `FileSource` is bound automatically on the Node entry and is Node-only. It reads a clone of `process.env` and the `.env` cascade.
* `PortableSource` wraps any object you pass, for every runtime without a filesystem. Pass the Cloudflare `env` binding on Workers (see [Workers](/docs/workers)), or the config your bundler injects into a browser build like `import.meta.env` (see [Browser](/docs/browser)).
`PortableSource` snapshots the object at construction (so later mutation does not leak in) and JSON-stringifies non-string values so the converters parse them the same way.
You pass the source the object the runtime exposes, the Worker `env` binding or the bundler's injected config, not one you write out by hand. The value is the typed read on top:
```ts twoslash
import { Envapter, PortableSource, Converters } from 'envapt';
// ---cut---
declare const env: Record; // the Cloudflare env binding
Envapter.useSource(new PortableSource(env));
const upstream = Envapter.getUsing('UPSTREAM_URL', Converters.Url); // URL | undefined
const cacheTtl = Envapter.getUsing('CACHE_TTL', Converters.Time, '5m'); // number (ms)
```
## Any object can be a source [#any-object-can-be-a-source]
`Source` is one required method, `readVars(): Record`. Any object that satisfies it works, so you can adapt another config system, a secrets payload or an RPC response, without writing a provider.
```ts twoslash
import { Envapter, Converters } from 'envapt';
import type { Source } from 'envapt';
// ---cut---
declare const secrets: Record; // fetched from your secrets manager
const source: Source = { readVars: () => secrets };
Envapter.useSource(source);
const apiBase = Envapter.getUsing('API_BASE_URL', Converters.Url);
const poolSize = Envapter.getNumber('DB_POOL_SIZE', 10);
```
An optional `supportsFiles` flag marks a source as backing the file-only config APIs. Only `FileSource` sets it, which is why the `.env` cascade and `envPaths` work on Node and nowhere else.
For fetching that secrets object from 1Password, Vault, Doppler, AWS Secrets Manager, and others, see [Using secret stores](/docs/secret-stores).
## A function can be a source [#a-function-can-be-a-source]
Some sources read one key at a time and give you no way to list the keys. Pass a `(key) => string | undefined` reader to `useSource` and envapt calls it per key on the first read, caching the result. No object to assemble.
```ts twoslash
import { Envapter, Converters } from 'envapt';
// ---cut---
declare const store: { get(key: string): string | undefined }; // a keyed config store
Envapter.useSource((key) => store.get(key));
const region = Envapter.getRequired('REGION', Converters.String);
```
## Combining sources [#combining-sources]
`merge` composes several sources into one, read last-wins. A later member overrides an earlier member's key. Keep the `.env` cascade and layer fetched secrets on top in a single bind, with no write to `process.env`.
```ts twoslash
import { Envapter, FileSource, PortableSource, merge, Converters } from 'envapt';
// ---cut---
declare const secrets: Record; // fetched and awaited
Envapter.useSource(merge(new FileSource(), new PortableSource(secrets)));
const dbUrl = Envapter.getUsing('DATABASE_URL', Converters.Url);
```
At most one member is filesystem-backed, and the `.env` cascade and file APIs route to it. `merge` throws [`InvalidMergedSource`](/docs/errors) with no members or more than one file-backed member.
A member can also be a `(key) => string | undefined` reader, so a keyed store fills any key the `.env` files do not define.
```ts twoslash
import { Envapter, FileSource, merge, Converters } from 'envapt';
// ---cut---
declare const store: { get(key: string): string | undefined };
Envapter.useSource(merge(new FileSource(), (key) => store.get(key)));
const dbUrl = Envapter.getUsing('DATABASE_URL', Converters.Url);
```
## When nothing is bound [#when-nothing-is-bound]
Reading a value before you call `useSource` throws `EnvaptError` with code `NoSourceBound`. It cannot happen on Node, where the source is bound on import.
The file-only config APIs need a source that backs the filesystem. On the portable build they warn and no-op by default, see [Compatibility](/docs/compatibility) for `fileApiMode`.
On the portable build there is no filesystem, so pass your values through `PortableSource`. An unbound read
throws `NoSourceBound`.
For the per-runtime flows see [Workers](/docs/workers) and [Browser](/docs/browser); the build selection is in [Compatibility](/docs/compatibility).
# Standard Schema (/docs/standard-schema)
`parse` validates an environment value through any [Standard Schema](https://standardschema.dev) validator, zod, valibot, arktype, or your own, and returns the schema's output type. envapt carries no schema library of its own; it reads the `~standard` interface the validator already exposes, so there is no peer dependency.
```ts twoslash
import { z } from 'zod/v4';
import { Envapter } from 'envapt';
// ---cut---
const port = Envapter.parse('PORT', z.coerce.number().int().min(1024).max(65535));
// ^?
```
A schema works on the [`@Envapt`](/docs/decorators) decorator too, through the `schema` option:
```ts twoslash
import { z } from 'zod/v4';
import { Envapt, Envapter } from 'envapt';
// ---cut---
class Config extends Envapter {
@Envapt('LOG_LEVEL', { schema: z.enum(['debug', 'info', 'warn', 'error']) })
static accessor logLevel: 'debug' | 'info' | 'warn' | 'error';
}
```
## Any conformant validator, or none [#any-conformant-validator-or-none]
Because envapt reads the Standard Schema interface, a hand-written validator works with no dependency at all. The return type is inferred from the schema's output.
```ts twoslash
import { Envapter } from 'envapt';
import type { StandardSchemaV1 } from 'envapt';
// ---cut---
const positiveInt: StandardSchemaV1 = {
'~standard': {
version: 1,
vendor: 'app',
validate(value) {
const n = Number(value);
if (Number.isInteger(n) && n > 0) return { value: n };
return { issues: [{ message: 'must be a positive integer' }] };
}
}
};
const port = Envapter.parse('PORT', positiveInt);
// ^?
```
## Fallbacks [#fallbacks]
`parse` takes an optional third argument. On a missing value it returns the fallback.
```ts twoslash
import { Envapter } from 'envapt';
import type { StandardSchemaV1 } from 'envapt';
declare const positiveInt: StandardSchemaV1;
// ---cut---
const port = Envapter.parse('PORT', positiveInt, 3000);
// ^?
```
The fallback is returned as-is on a missing value; it does not pass through the schema. Make sure the fallback
already satisfies the type you expect, since the schema never sees it.
An explicit `undefined` third argument counts as no fallback. `Envapter.parse('PORT', schema, undefined)` throws
`MissingEnvValue` on a missing value, the same as omitting the argument.
## What throws [#what-throws]
`parse` throws `EnvaptError` in three cases:
* The value is missing and you gave no fallback: code `MissingEnvValue`.
* The schema reports validation issues: code `SchemaValidationFailed`. The error carries the validator's full `issues` array on its `.issues` property.
* The schema's `validate` itself throws: code `SchemaThrew`, with the original error on `.cause`.
A `${VAR}` template is expanded before validation, so the schema sees the resolved value.
envapt validates at config-load time and accepts synchronous schemas only. A schema whose `validate` returns a
`Promise` is a type error, and throws `EnvaptError` with code `InvalidUserDefinedConfig` if you force it past the
types. `schema` and `converter` are mutually exclusive, since each one already turns the raw string into a typed
value.
# Strict Mode (/docs/strict-mode)
Strict mode is a global switch that tightens how envapt treats whitespace, unresolved templates, and empty array elements. It's off by default.
```ts twoslash
import { Envapter } from 'envapt';
// ---cut---
Envapter.strict = true;
```
Setting it refreshes the cache, so values already read are re-evaluated under the new rule.
## What strict mode changes [#what-strict-mode-changes]
Three behaviors tighten when strict mode is on:
1. **Whitespace counts as missing.** Normally only an unset variable or an empty string is missing. Under strict mode a value that is only whitespace is missing too, so it falls back the same way an unset variable would.
2. **Unresolved templates throw.** A `${VAR}` reference that can't be resolved is left as literal text by default. Under strict mode it throws `EnvaptError` with code `MissingEnvValue`. The same applies to a missing key in the [`resolve`](/docs/templates#the-resolve-tagged-template) tagged template.
3. **Empty array elements throw.** `Converters.array` drops empty elements by default. Under strict mode an empty element (a trailing comma, say) throws `EnvaptError` with code `EmptyArrayElement`.
Strict mode is global and retroactive: the setter clears the cache, so the next read of any value re-runs under the
new rule. Set it once, early, alongside your other envapt configuration.
## Strict mode does not make every read throw [#strict-mode-does-not-make-every-read-throw]
A plain reader still returns the fallback (or `undefined`) for a missing value, even under strict mode. Strict mode changes how whitespace, templates, and array elements are treated; it does not turn every missing variable into a throw.
To fail fast on a specific value, use `require` or `getRequired`. These throw on an unset or empty value in any mode, and on a whitespace-only value under strict mode.
```ts twoslash
import { Envapter, Converters } from 'envapt';
// ---cut---
// throws if unset, independent of Envapter.strict
const s3Endpoint = Envapter.getRequired('S3_ENDPOINT_URL', Converters.Url);
// ^?
// A decoupled require call. Can be used at the top level of a module to fail fast
// on all missing values in one spot instead of scattering them throughout the codebase.
Envapter.require('API_KEY', 'SENTRY_DSN');
```
Use `getRequired` / `require` when one specific value must be present. Turn on strict mode when you want stricter
whitespace, template, and array-element rules everywhere.
See [Fail-fast on missing values](/docs/envapter#fail-fast-on-missing-values) for the reader API and [Errors](/docs/errors) for the codes.
# Templates (/docs/templates)
A value can reference another with `${VAR}`. envapt expands those references when you read the value, whatever source the values came from, so you compose values instead of repeating them.
```dotenv
DB_HOST=localhost
DB_PORT=5432
DATABASE_URL=postgres://${DB_HOST}:${DB_PORT}/app
```
```ts twoslash
import { Envapter } from 'envapt';
// ---cut---
const url = Envapter.get('DATABASE_URL');
// "postgres://localhost:5432/app"
```
Expansion runs on read, through every reader (`get`, `getNumber`, `getUsing`, and the rest), not when the file loads.
## Nested references [#nested-references]
A referenced variable can itself contain references. envapt resolves them transitively.
```dotenv
PROTOCOL=https
HOST=api.example.com
BASE_URL=${PROTOCOL}://${HOST}
HEALTHCHECK=${BASE_URL}/health
```
Reading `HEALTHCHECK` returns `https://api.example.com/health`.
## Unresolved references [#unresolved-references]
When a referenced variable is missing or empty (or, under strict mode, whitespace-only), envapt leaves the literal `${VAR}` text in place.
```dotenv
DB_HOST=localhost
DATABASE_URL=postgres://${DB_HOST}:${DB_PORT}/app
```
With `DB_PORT` unset, reading `DATABASE_URL` returns `postgres://localhost:${DB_PORT}/app`: the resolved `${DB_HOST}`, and the literal `${DB_PORT}` left in place. Enable [debug mode](/docs/configuration#debug-mode) to log each unresolved reference.
Turn on strict mode if an unresolved reference shoud throw `EnvaptError` with code `MissingEnvValue` instead.
## Cycle protection [#cycle-protection]
A variable that references itself, directly or through a chain, does not loop. envapt detects the cycle and leaves the looping reference as its literal text.
```dotenv
A=${B}
B=${A}
```
Reading `A` returns `${B}` rather than recursing forever.
## Syntax limits [#syntax-limits]
The only recognized form is `${NAME}`, where `NAME` is letters, digits, and underscores.
There is no default-value syntax. `${VAR:-fallback}` is not parsed as a default, the whole token is treated as the
variable name and left unresolved if it has no match. Pass a fallback to the reader instead, e.g.
`Envapter.get('VAR', 'fallback')`.
There is also no escape sequence. A `$` that is not followed by `{...}` is left untouched, so `price: $5` stays `price: $5`.
## The resolve tagged template [#the-resolve-tagged-template]
`resolve` builds a string from keys at the call site, and expands any `${VAR}` references inside the resolved values. Each `${...}` interpolation in the template is an env var name, not a JavaScript value.
```ts twoslash
import { Envapter } from 'envapt';
// ---cut---
// API_HOST=api.example.com, API_PORT=8080
const endpoint = Envapter.resolve`https://${'API_HOST'}:${'API_PORT'}`;
// ^?
```
`resolve` returns a `string`. Under strict mode, a missing, empty, or whitespace-only key throws `EnvaptError` with code `MissingEnvValue`. Otherwise a missing or empty key resolves to an empty string, and a whitespace-only key keeps its value.
`resolve` does not preserve unresolved keys as literal text the way `.env` expansion does. In non-strict mode a
missing or empty key collapses to an empty string in the output, and a whitespace-only key keeps its value. Use strict mode if a missing key should throw.
# Workers (/docs/workers)
On Cloudflare Workers (workerd) there is no filesystem and no ambient `process.env`. Your configuration is the `env` object the runtime exposes. Bind a `PortableSource` from it once, then read with the same typed API as everywhere else.
Import from `envapt`. The package export conditions resolve a node-free portable build on Workers automatically, so the same import works in local dev and on the Worker.
The filesystem-only config APIs have nothing to do on Workers, so they warn and no-op by default. Readers work as normal. See [Compatibility](/docs/compatibility) for `fileApiMode` and [Errors](/docs/errors) for the codes.
```ts twoslash
// @lib: esnext
///
// ---cut---
import { env } from 'cloudflare:workers';
import { Converters, Envapter, PortableSource } from 'envapt';
Envapter.useSource(new PortableSource(env));
Envapter.getUsing('CACHE_TTL', Converters.Time, '1m'); // readers work once a source is bound
Envapter.envPaths = ['.env']; // warns once and no-ops on Workers
```
## Bind once at module load [#bind-once-at-module-load]
Env vars and secrets are readable at module scope through `cloudflare:workers`, and they are constant for a deployment. Bind the source in a config module and export the typed values the Worker needs. Binding then happens once per isolate, and every request reads the exported values.
```ts title="src/config.ts" twoslash
// @lib: esnext
///
// ---cut---
import { env } from 'cloudflare:workers';
import { Envapter, PortableSource, Converters } from 'envapt';
Envapter.useSource(new PortableSource(env));
const values = Envapter.getRequiredAll(
{
ORIGIN_URL: Converters.Url,
API_TOKEN: Converters.String
},
'camelCase'
);
export const config = {
...values,
cacheTtl: Envapter.getUsing('CACHE_TTL', Converters.Time, '1m')
};
```
```ts title="src/index.ts" twoslash
// @lib: esnext
// @filename: config.ts
///
import { env } from 'cloudflare:workers';
import { Envapter, PortableSource, Converters } from 'envapt';
Envapter.useSource(new PortableSource(env));
const values = Envapter.getRequiredAll(
{
ORIGIN_URL: Converters.Url,
API_TOKEN: Converters.String
},
'camelCase'
);
export const config = {
...values,
cacheTtl: Envapter.getUsing('CACHE_TTL', Converters.Time, '1m')
};
// @filename: index.ts
// ---cut---
import { config } from './config';
export default {
async fetch(request: Request): Promise {
if (request.headers.get('authorization') !== `Bearer ${config.apiToken}`) {
return new Response('Unauthorized', { status: 401 });
}
const url = new URL(request.url);
return fetch(new URL(url.pathname + url.search, config.originUrl), request);
}
} satisfies ExportedHandler;
```
`useSource` sets process-global state shared across the isolate. That is safe here because a Worker's `env` is
constant for a deployment, so binding it once is idempotent. Do not put per-request data, the `request` or a user
id, into the source.
## If you cannot import `env` [#if-you-cannot-import-env]
On an older compatibility date, or anywhere `env` is not importable at module scope, bind it at the top of the handler instead. The `env` is the same on every request, so the re-bind is idempotent.
```ts title="src/index.ts" twoslash
// @lib: esnext
///
// ---cut---
import { Envapter, PortableSource, Converters } from 'envapt';
export default {
async fetch(request: Request, env: Record): Promise {
Envapter.useSource(new PortableSource(env));
const origin = Envapter.getRequired('ORIGIN_URL', Converters.Url);
return fetch(new URL(new URL(request.url).pathname, origin), request);
}
} satisfies ExportedHandler>;
```
## Non-string bindings [#non-string-bindings]
A Cloudflare `vars` entry can hold JSON as well as a string. `PortableSource` JSON-stringifies any non-string binding so the converters parse it the same way. A binding whose value stringifies to `undefined` is dropped, and anything you read this way must be JSON-serializable.
```ts twoslash
import { Envapter, PortableSource, Converters } from 'envapt';
// ---cut---
declare const env: { FEATURE_FLAGS: { beta: boolean } }; // a JSON `vars` binding
Envapter.useSource(new PortableSource(env));
const flags = Envapter.getUsing('FEATURE_FLAGS', Converters.Json); // parsed back from the stringified binding
```
## No filesystem [#no-filesystem]
There is no `.env` cascade and no custom profiles on workerd. Bind the source before your first read, an unbound read throws `NoSourceBound`. See [Sources](/docs/sources).
## Decorators [#decorators]
A decorated property caches its value on first read, and Cloudflare `env` scalars are stable per isolate, so the cached value matches the binding. The default accessor decorators work on Workers, the legacy `envapt/legacy` form needs a compile step.
Configure Worker `vars` and bindings through Cloudflare's [environment variables](https://developers.cloudflare.com/workers/configuration/environment-variables/) docs. For the providers and the contract see [Sources](/docs/sources).