v8.1.0

Migration (v7 to v8)

Moving from envapt 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.

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

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.

v7v8
ManualEnvSource, WorkerEnvSourcePortableSource
NodeEnvSourceFileSource
EnvSource (type)Source
// 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

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.

// 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

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'.

// 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

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

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.

// 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

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<typeof schema> uses your validator's own inference (z.infer, valibot's InferOutput, arktype's .infer) or StandardSchemaV1.InferOutput.

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.

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

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.

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.

// 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

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.

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 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.

// 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 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.

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

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.

// 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.

On this page