v9.1.0

Migration (v7 to v8)

Upgrade from envapt v7 to v8, where the source classes get shorter names and one envapt import replaces the workerd and browser subpaths.

v8 has these breaking changes:

  1. The source classes are renamed.
  2. The envapt/workerd and envapt/browser subpaths are removed.
  3. On the portable build, the filesystem-only config APIs warn once and do nothing by default.
  4. The public type exports are trimmed.
  5. The required form of getUsing and getWith moves to getRequired.
  6. The Integer and Float converters reject malformed values.
  7. One rule decides when a value is missing, so ordered reads skip an empty key and whitespace-only values follow strict.
  8. Built-in converter fallbacks are checked by value.
  9. A missing read with no fallback resolves to undefined on every reader.
  10. getWith passes the raw value to its custom converter.
  11. An explicit undefined fallback counts as no fallback.

Most upgrades touch only the first two. The rest apply only if you imported an internal type by name, typed a decorator field as | null, or relied on loose parsing, and each section says when it affects you.

Source and type renames

The source class names lose the Env infix. PortableSource is the source for any runtime where envapt can't read files, 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));

In v7.1, PortableSource and Source already existed, and ManualEnvSource / WorkerEnvSource / EnvSource were deprecated aliases. If you were already on v7.1, v8 only removes those aliases. NodeEnvSource is renamed to FileSource in v8.

One universal envapt import

The envapt/workerd and envapt/browser subpaths are gone. Import the main API from envapt on every runtime. 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 load the node build now resolve the portable build through the edge-light, fastly, and worker conditions. Vercel Edge and Cloudflare Workers set their conditions without any setup. On Fastly Compute, add the fastly condition to your bundler's resolve conditions. envapt/config and envapt/legacy are unchanged.

File-only APIs warn and do nothing

On the portable build, the filesystem-only config APIs (envPaths, baseDir, envFileOptions, configureProfiles, resetProfiles) used to throw FileApiUnsupported. They now warn once and do nothing 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 does nothing 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

A read with no bound source still throws NoSourceBound on the first access, whatever fileApiMode is, so a Worker that never binds a source throws before it serves any config. fileApiMode affects only the file APIs on the portable build. 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 adds them to the portable types, so a config module shared between your Node dev setup and a Workers deploy type-checks the same in both places. At runtime each call warns once and does nothing, as above, or throws under fileApiMode = 'throw'.

Required reads move to getRequired

The { required: true } options-object form of getUsing and getWith is removed. A required read is now getRequired(key, converter), which takes the converter as its second argument, returns a value that's never undefined, 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.Port
});

getUsing and getWith keep their positional fallback forms unchanged. The @Envapt decorator keeps its { required: true } option. Only the functional readers lost the options object.

Trimmed public type exports

v8 exports 15 types, down from 36. These internal types that v7 exported are gone:

  • the source shape interfaces (BareEnvSource / FileEnvSource)
  • the decorator return types
  • the schema brands
  • the converter and inference helper types
  • EnvKeyInput
  • ArrayOf / ArrayElement
  • the InferSchemaInput / InferSchemaOutput aliases

Inference on the readers and decorators is unchanged, since those types resolve at the call site.

This affects you only if you imported one of these by name.

  • If you typed a custom source against BareEnvSource / FileEnvSource, type it against the Source union.
  • Replace InferSchemaOutput<typeof schema> with 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 larger than 2^53 lost precision. v8 parses both with Number and checks the result. If Number rejects a value, the read returns the fallback, or throws under getRequired.

Integer requires Number.isSafeInteger, so 42abc, 3.9, and values larger than 2^53 in magnitude 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)
// v9: throws InvalidEnvValue (210)

Change something here only if your app relied on the loose parse. A value that's 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 if it's unset or an empty string. Under Envapter.strict = true, a whitespace-only value is missing too. Two v7 behaviors change.

Ordered reads skip a present-but-empty key. An ordered read (get, getNumber, the decorators, or an array of keys) skips 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. 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. 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. A fallback the converter rejects now throws FallbackConverterTypeMismatch, even where v7 accepted it. That covers a NaN Number or Float fallback, a non-safe-integer Integer fallback, and 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

This matters only if you passed a placeholder fallback such as NaN that isn't 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 too, so one value marks an absent read across the whole API.

A decorator field with no fallback is now typed T | undefined. 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. For a missing key the converter receives raw === undefined. v7 returned the fallback for a missing key without running the converter. The converter already received the fallback as the second argument, so you can still return it when raw is missing.

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. In v8, the ConverterFunction type declared raw as string | undefined and the fallback as the second parameter. The @Envapt custom-converter decorator already ran the converter on a missing key, so v8 makes getWith behave the same way.

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