# 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<typeof schema>` 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.
