envapt · git:20261019.99e7d64 · 2026-10-19 · sha256 d8d35a3f4bf7e149
envapt git:20261019.99e7d64A
Immutable. This exact content never changes and is served at /api/v1/blob/d8d35a3f4bf7e149.
---
name: envapt
description: Reads typed, validated config from process.env, .env files, or any bound source for TypeScript and JavaScript with the envapt library, on Node, Bun, Deno, Cloudflare Workers, and the browser. Use when the project imports from 'envapt' or 'envapt/config', when the user mentions envapt, Envapter, or @Envapt, or when the task involves reading typed environment variables, loading .env files, validating env with zod/valibot/arktype, binding config on Cloudflare Workers or in the browser, or migrating off dotenv or the removed 'envapt/workerd' / 'envapt/browser' subpaths.
---
# envapt
envapt reads environment variables, or any config, as typed, validated values, with an optional `.env` cascade layered on top of `process.env`, and nothing in `dependencies` or `peerDependencies`. Available as `envapt` on npm and `@materwelon/envapt` on JSR. Minimum versions are Node >=22.23.3, Bun >=1.3.11, Deno >=2.5. Ships ESM only. It also runs on Cloudflare Workers and in the browser, where you bind a source yourself and read with the same typed API (see the Runtimes and sources section below). Import from `envapt` everywhere, because the package export conditions select the right build automatically.
## Choosing the API: functional vs decorators
envapt has two surfaces. They read the same values through the same cache. Pick by the project.
- **Default to the functional `Envapter` readers.** They work in JS and TS with no build step and no compiler flags, on every runtime envapt supports (Node, Bun, Deno, Cloudflare Workers, the browser). Off Node you bind a source first (see the Runtimes and sources section below).
- **The `@Envapt` decorators are TypeScript-only.** The default import is a TC39 Stage 3 accessor decorator, so it needs no `experimentalDecorators` flag and runs on any runtime that supports Stage 3 decorators, including Bun and Deno executing a `.ts` file directly. Use it on a TS project that wants config on typed fields.
- **Legacy decorators live at `envapt/legacy`.** Use them when the project already runs on `experimentalDecorators`. They keep the old `static readonly` / `declare readonly` form and need that flag set in `tsconfig.json`.
## Functional API (the portable default)
`Envapter` exposes the readers as static methods. Every key argument accepts a single string or an ordered array (first key that has a value wins).
```ts
import { Envapter, Converters } from 'envapt';
import { z } from 'zod/v4';
Envapter.get('API_KEY'); // string | undefined
Envapter.get('API_KEY', 'dev-key'); // string
Envapter.getAs('MAX_RETRIES', Converters.Integer, 3); // number
Envapter.getAs('DEBUG', Converters.Boolean, false); // boolean. true set: 1/yes/true/on, false set: 0/no/false/off
Envapter.getAs('MAX', Converters.BigInt, 0n); // bigint
Envapter.get(['CANARY_URL', 'APP_URL']); // ordered fallback
// Presence check, true exactly when a required read of the same key finds a value
Envapter.has('SENTRY_DSN'); // boolean
// Built-in or array converter:
Envapter.getAs('ORIGINS', Converters.array({ of: Converters.String }), []); // string[]
// Custom function converter, called only when the variable is set:
Envapter.getAs('FLAGS', (raw) => raw.split(','), []); // string[]
// Standard Schema (zod, valibot, arktype):
Envapter.getAs('LOG_LEVEL', z.enum(['debug', 'info', 'warn']), 'info'); // 'debug' | 'info' | 'warn'
// Throw when a key is missing:
Envapter.require('DATABASE_URL', 'JWT_SECRET'); // throws EnvaptError listing every missing key
Envapter.require(['READONLY_URL', 'DATABASE_URL']); // an ordered key list passes when any key has a value
Envapter.getRequired('PORT', Converters.Port); // MissingEnvValue if unset, InvalidEnvValue if it can't convert
// throws if any key is unset (aggregates all missing keys so only one error), returns a typed object if all are present.
// a field can also be a Standard Schema, see "Validation with Standard Schema"
Envapter.getRequiredAll(
{
PORT: Converters.Port,
DATABASE_URL: Converters.Url,
CACHE_TTL: Converters.Time,
ALLOWED_ORIGINS: Converters.array()
},
'camelCase'
);
```
`Envapter.resolve` is a tagged template that interpolates env values: ``Envapter.resolve`postgres://${'DB_HOST'}:${'DB_PORT'}` ``. A slot can also take an ordered key list, as in `${['READONLY_HOST', 'DB_HOST']}`. `Envapter.getRaw('KEY')` returns the raw string with no parsing.
## Decorators
`@Envapt` binds a class property to a variable. The default import is a TC39 Stage 3 accessor decorator, declared with the `accessor` keyword. The class does **not** need to extend anything. Extend `Envapter` only to also get the reader methods on the same class.
```ts
import { Envapt, Converters } from 'envapt';
class Config {
@Envapt('PORT', { converter: Converters.Port, fallback: 3000 })
static accessor port: number;
@Envapt('DATABASE_URL', { converter: Converters.Url, required: true })
static accessor databaseUrl: URL;
}
```
### Field declaration
Declare the field with the `accessor` keyword. Static fields use `static accessor x: T`, instance fields use `accessor x!: T` with the definite-assignment `!`. No `readonly`, no `declare`, no initializer, and no `experimentalDecorators` flag. The accessor is read-only, so assigning to it throws `EnvaptError`.
The legacy form (`envapt/legacy`) differs. Static fields use a plain `static readonly x: T` and instance fields use `declare readonly x: T` (both no initializer), and `experimentalDecorators` must be on in `tsconfig.json` (and `deno.json` on Deno). A module that imports decorators only from `envapt/legacy` must also `import 'envapt'` once so the runtime source binds.
### Options object
`@Envapt(key, options)` accepts: `{ fallback }`, `{ converter, fallback }`, `{ converter }`, `{ converter, required: true }`, `{ required: true }` (raw string, throws on missing), `{ schema }` (`undefined` on missing), `{ schema, fallback }`, and `{ schema, required: true }`. `required` and `fallback` are mutually exclusive, and so are `schema` and `converter`. Passing a Standard Schema as `converter` throws `InvalidUserDefinedConfig`, so give it to `schema`. With `required: true`, a present value the converter can't convert throws `InvalidEnvValue`. A bad combination throws `EnvaptError` when the decorator is applied, before any access.
## Converters
`Converters` carries the scalar tokens `String`, `Number`, `Integer`, `Float`, `Boolean`, `BigInt`, `Json`, `Url`, `RegExp`, `Date`, `Time`, `Port`, `Email`. Four carry semantics the token name alone does not give.
- `Time` resolves to **milliseconds**. Its fallback is a ms number or a time string like `'10s'`/`'15m'`.
- `Url` resolves to a **`URL` instance**. Its fallback must be a `URL`, and a string fails to compile.
- `Port` resolves to a `number` and returns the fallback for anything outside the `0-65535` integer range. Use it for every port variable.
- `Email` resolves to the raw `string` and returns the fallback for an address that fails the WHATWG email pattern.
Arrays use the builder. `of` defaults to `Converters.String` and `delimiter` to `','`. `of` accepts any scalar token except `json` and `regexp`, or a custom element function.
```ts
Converters.array({ of: Converters.Number, delimiter: ';' }); // "1;2;3" -> number[]
```
A custom converter is a `(raw: string) => T` function everywhere: `getAs`, `@Envapt({ converter })`, `getRequired`, `getRequiredAll`, and `Converters.array({ of })`. envapt calls it only with a present value and applies the fallback itself. Returning `undefined` or `null` means the value can't be converted. A top-level read then uses the fallback, and an array element throws `ArrayElementConversionFailed`. An error the converter throws reaches the caller unchanged from `getAs` and an optional `@Envapt`. A required read reports it as an `InvalidEnvValue` issue carrying its message.
## Runtimes and sources
envapt reads through a pluggable `Source`, and the build you import binds the right one. Import from `envapt` everywhere, because the `exports` conditions select the build automatically.
- **Node, Bun, and Deno** (`import ... from 'envapt'`): a `FileSource` is bound automatically (a `process.env` snapshot plus the `.env` cascade). Nothing to wire up.
- **Cloudflare Workers** (`import ... from 'envapt'`): bind the `env` binding yourself, once, before any read.
```ts
import { Envapter, PortableSource } from 'envapt';
import { env } from 'cloudflare:workers'; // or the `env` passed to fetch(request, env)
Envapter.useSource(new PortableSource(env)); // bind once at module load
```
- **Browser** (`import ... from 'envapt'`): seed a `PortableSource` from the object your bundler injects.
```ts
import { Envapter, PortableSource } from 'envapt';
Envapter.useSource(new PortableSource(import.meta.env)); // Vite, or a webpack DefinePlugin object
```
`PortableSource` snapshots the object and JSON-stringifies non-string values, so you pass the runtime's object straight through. Any object with a `readVars(): Record<string, string>` method is a valid `Source`.
For a source that reads one key at a time and cannot list its keys, pass a `(key) => string | undefined` reader to `useSource`, and envapt calls it per key on the first read, caching the result.
`merge(...members)` composes several sources, last-wins across their `readVars()` snapshots. A member may be a `(key) => string | undefined` reader, which fills a key missing from every snapshot. At most one member is filesystem-backed, and the `.env` cascade and file APIs route to it. `merge` throws `EnvaptError` `InvalidMergedSource` with no members or more than one file-backed member.
The portable build (Workers, the browser, edge runtimes) has no filesystem. The file-only config APIs (`envPaths`, `baseDir`, `envFileOptions`, `configureProfiles`, `resetPaths`) warn once and no-op by default. Set `Envapter.fileApiMode = 'throw'` to make them throw `EnvaptError` `FileApiUnsupported`. A read before `useSource` throws `NoSourceBound` regardless of `fileApiMode`.
## Loading .env files
By default envapt loads a per-environment cascade. Precedence is **most-specific-wins**: `.env.<environment>.local` > `.env.<environment>` > `.env.local` > `.env`, the same order dotenv-flow uses. Next.js loads `.env.local` above `.env.<environment>`. Configure through statics on `Envapter`:
```ts
Envapter.baseDir = import.meta.dirname; // anchor relative .env resolution to this dir
Envapter.envPaths = ['.env', '.env.local']; // explicit paths, which disables the auto-cascade. Set baseDir first.
Envapter.strict = true; // whitespace-only values count as missing
Envapter.syncProcessEnv = true; // mirror loaded keys into process.env
Envapter.environment; // 'development' | 'staging' | 'production' | 'test'. also isProduction / isStaging / isDevelopment / isTest
```
The environment is detected from the first set of `ENVIRONMENT` -> `ENV` -> `NODE_ENV` -> `MODE` (case-insensitive, and `MODE` covers Vite browser builds), defaulting to development. `ENVIRONMENT_KEYS` exports that list in order, for code that has to read the mode before envapt loads. `Envapter.configureProfiles({ ... })` sets per-environment path lists (node build only, like `baseDir` / `envPaths`). `Envapter.debug` (or the `ENVAPT_DEBUG` env var) turns on logging.
### Drop-in for dotenv
`import 'envapt/config'` loads the cascade and mirrors every key into `process.env`. It is the `dotenv/config` replacement:
```ts
import 'envapt/config'; // ESM, top of entry
// node --import envapt/config app.js (preload)
```
## Validation with Standard Schema
`Envapter.getAs`, `Envapter.getRequired`, and `@Envapt({ schema })` validate through any Standard Schema validator: zod, valibot, arktype, or a hand-rolled `StandardSchemaV1`. The result type is the schema's output. A missing value returns the fallback, or `undefined` without one. `getRequired` and `required: true` throw `MissingEnvValue` for it instead.
```ts
import { z } from 'zod/v4';
Envapter.getAs('PORT', z.coerce.number().int().min(1024).max(65535), 3000); // number
Envapter.getRequired('PORT', z.coerce.number().int().min(1024).max(65535)); // number, throws when unset
```
Two constraints: validation is **sync-only** (an async `validate` throws), and a **fallback is returned as-is and is NOT passed through the schema**. Only a present env value is validated.
`getRequiredAll` takes a schema for any field, mixed with converters. A bad converter in the spec throws first. Missing keys then throw one `MissingEnvValue`, before any schema runs. A schema `.default()` never applies there. After that envapt reads every field and throws one `InvalidEnvValue` for all the bad values: a schema or converter that rejects its value, a bad array element, or a custom parser or schema that throws. Each issue's `path` starts with its env key. The error message lists only the keys, since an issue message can quote a secret value.
```ts
const db = Envapter.getRequiredAll(
{
DATABASE_URL: Converters.Url,
DATABASE_POOL_SIZE: z.coerce.number().int().min(1).max(100),
DATABASE_SSL_MODE: z.enum(['disable', 'require', 'verify-full'])
},
'camelCase'
); // { databaseUrl: URL; databasePoolSize: number; databaseSslMode: 'disable' | 'require' | 'verify-full' }
```
## Errors
Reader and decorator failures throw `EnvaptError` with a numeric `code` (`EnvaptErrorCodes`). The common error codes are `MissingEnvValue` (a required value is absent), `SchemaValidationFailed` (`getAs` or a `schema` decorator without `required` gets a value its schema rejects), `InvalidEnvValue` (a required read gets a value it can't convert, a bad array element, or an error from a custom parser or schema). Both carry an `issues` array whose paths start with the env key, and both messages list only the keys. In a required read, an issue from a parser or schema that threw keeps the thrown value as `cause`, and the error's own `cause` is that value when exactly one issue has one. Other common codes are `InvalidUserDefinedConfig` (bad options, e.g. `required` + `fallback`), `MalformedTimeFallback`, `FileApiUnsupported` (a file-only API called with `fileApiMode = 'throw'`, or on the node build with a non-filesystem source), `NoSourceBound` (a read before `useSource` on the portable build).
```ts
import { EnvaptError, EnvaptErrorCodes } from 'envapt';
try {
Envapter.require('DATABASE_URL');
} catch (err) {
if (err instanceof EnvaptError && err.code === EnvaptErrorCodes.MissingEnvValue) {
/* recover */
}
}
```
## Migrating from dotenv
envapt loads `.env` **and** returns typed, validated values from one API, with nothing in `dependencies` or `peerDependencies`.
<!--prettier-ignore-start-->
| dotenv | envapt |
| --- | --- |
| `require('dotenv').config()` | `import 'envapt/config'` |
| `-r dotenv/config` | `--import envapt/config` |
| `process.env.API_KEY` | `Envapter.get('API_KEY')` / `Envapter.require('API_KEY')` |
| `Number(process.env.PORT) \|\| 3000` | `Envapter.getAs('PORT', Converters.Port, 3000)` |
| `process.env.DEBUG === 'true'` | `Envapter.getAs('DEBUG', Converters.Boolean, false)` |
<!--prettier-ignore-end-->
## Gotchas
<!--prettier-ignore-start-->
| Gotcha | Rule |
| --- | --- |
| Wrong decorator field form | Modern uses `static accessor x: T` / `accessor x!: T`. Legacy (`envapt/legacy`) uses `static readonly x: T` / `declare readonly x: T`, both no initializer |
| `Converters.Url` fallback | Pass a `URL` instance: `new URL('...')` |
| Reading a port | Use `Converters.Port`, which range-checks `0-65535`. `Converters.Number` accepts `-1` and `99999` |
| `Converters.Time` | Resolves to milliseconds. Fallback is a ms number or a time string (`'15m'`) |
| Schema fallback | A fallback is returned as-is. Only present env values pass through the schema |
| `required` + `fallback` | Mutually exclusive, and so are `schema` + `converter` |
| Cascade order | Most-specific-wins: `.env.<env>.local` > `.env.<env>` > `.env.local` > `.env` |
| Off Node (Workers/browser) | Import from `envapt`. Call `Envapter.useSource(...)` before reading (else `NoSourceBound`). File APIs warn once and no-op by default (`fileApiMode = 'throw'` to throw `FileApiUnsupported`) |
<!--prettier-ignore-end-->
## Old patterns (recognize, then migrate)
<details>
<summary>Deprecated positional <code>@Envapt('KEY', fallback, Converter)</code> form</summary>
Pre-v5 code uses a positional signature: `@Envapt('PORT', 3000, Number)`. It is **deprecated in v5 and removed in v6**, and it can only express a primitive-constructor converter (no built-in tokens, arrays, custom functions, `schema`, or `required`). When you see it, migrate to the options object:
```ts
// old
@Envapt('PORT', 3000, Number) static readonly port: number;
// new
@Envapt('PORT', { converter: Converters.Number, fallback: 3000 }) static accessor port: number;
```
</details>
<details>
<summary>Pre-v7 legacy decorator field form (<code>static readonly</code> / <code>declare readonly</code>)</summary>
Pre-v7 code imports `@Envapt` from `envapt` and declares fields with `static readonly` / `declare readonly`. In v7 the default `envapt` decorators are TC39 accessors. Migrate the fields to `static accessor x: T` / `accessor x!: T`, or keep the old form by importing from `envapt/legacy` (which still needs `experimentalDecorators`).
```ts
// pre-v7 (now the legacy form, still on envapt/legacy)
@Envapt('PORT', { fallback: 3000 }) static readonly port: number;
// v7 default
@Envapt('PORT', { fallback: 3000 }) static accessor port: number;
```
</details>
<details>
<summary>Removed in v8: <code>envapt/workerd</code> and <code>envapt/browser</code> subpaths</summary>
In v8 these subpaths were removed. Import from `envapt` directly, because the package `exports` conditions resolve the portable build on Workers, edge runtimes, the browser, and react-native automatically. The portable types now include the file-only APIs, which warn once and no-op by default (set `Envapter.fileApiMode = 'throw'` for a hard `FileApiUnsupported` error). No type-only subpath is needed.
```ts
// v7 and earlier
import { Envapter, WorkerEnvSource } from 'envapt/workerd';
import { Envapter, ManualEnvSource } from 'envapt/browser';
// v8
import { Envapter, PortableSource } from 'envapt';
```
</details>
---
Full reference (every converter, method, and option): <https://envapt.materwelon.dev>