Migration (v8 to v9)
Move a v8 app to envapt v9, which requires Node 22.23.3 and ESM, and find the behavior changes that still compile.
v9 requires Node 22.23.3 or newer, and it ships as ESM only. Most of its other changes are renames and removals, and in TypeScript each one is a compile error. The behavior changes below are the ones to search for by hand.
Before you upgrade
Node 20 reached end of life in April 2026, so v9 raises the minimum Node version from 20 to 22.23.3. Bun >=1.3.11 and Deno >=2.5 haven't changed.
envapt also stopped shipping a CommonJS build. The entries Node matches in its package exports only list import, which makes require('envapt') throw ERR_PACKAGE_PATH_NOT_EXPORTED.
| v8 | v9 |
|---|---|
const { Envapter } = require('envapt') | const { Envapter } = await import('envapt') |
node -r envapt/config app.js | node --import envapt/config app.js |
CommonJS has no top-level await, so put the import() inside the async function that starts your app.
Behavior changes
Most of the code these sections describe still compiles in v9, so search your project for each one.
Required reads throw InvalidEnvValue
In v8, a required read threw MissingEnvValue (305) both for an unset key and for a value it couldn't convert. A catch had no way to tell a missing PORT from a mistyped one. v9 keeps 305 for the unset key and throws the new InvalidEnvValue (210) for a value that's set but bad. That covers getRequired, getRequiredAll, and @Envapt with required: true.
// with PORT="80a"
try {
.('PORT', .);
} catch () {
if ( instanceof && . === .) {
.(.?.(() => .));
}
}That prints [['PORT']]. Each failure is an entry in issues, with the env key first in its path. getRequiredAll throws one InvalidEnvValue holding every bad value. v8 stopped at the first one. If a key is also unset, getRequiredAll throws MissingEnvValue for the unset keys first.
With the decorator, v8 didn't throw at all:
class Config extends Envapter {
@Envapt('PORT', { converter: Converters.Port, required: true })
static accessor port: number;
}For PORT="80a", v8 gave Config.port the value undefined, even though the field is typed number. v9 throws InvalidEnvValue the first time you read it.
A required read also turns its other failures into issues on the InvalidEnvValue it throws. A bad array element used to throw ArrayElementConversionFailed or EmptyArrayElement. An error from your own parser reached your catch unchanged. A schema on a required decorator threw SchemaValidationFailed or SchemaThrew. Now each becomes an issue that carries the original message, and an error your parser or schema threw stays on that issue as cause. A catch on your parser's error class stops matching, so check for InvalidEnvValue there and read error.issues.
A bad converter throws earlier, with a different code
A converter token with a typo, like 'nmber', now throws InvalidBuiltInConverter (202) and lists the valid tokens. v8 threw InvalidCustomConverter (203).
A mistyped token also throws on every read now. v8 skipped the check for an unset key with no fallback, so getUsing('CACHE_TTL', 'nmber') returned undefined for as long as CACHE_TTL stayed unset. A required read throws that error before it checks whether the key is set.
These fallback checks now run on every read:
- A malformed time-string fallback, like
'10 seconds'onConverters.Time, throwsMalformedTimeFallback(105) even when the variable is set. v8 threw only when it used the fallback. getchecks that its fallback is a string, which v8 never did.get('PORT', 80)throwsFallbackConverterTypeMismatch(104).
TypeScript rejects a mistyped token. It also rejects a fallback of the wrong type on get, and on getAs with a token. This section mostly matters to JavaScript callers and to code that reads error.code.
A schema read with no fallback returns undefined
Envapter.parse threw MissingEnvValue for an unset key when you passed no fallback. Its replacement, getAs, returns undefined for that key, like every other optional read. A field declared with @Envapt('PORT', { schema }) reads as undefined too.
If you relied on the throw, move the read to getRequired, which now takes a Standard Schema:
const port = .('PORT', ..().().(1024));port is a number, and an unset PORT throws MissingEnvValue. On a decorator, add required: true. A schema field with neither a fallback nor required: true needs a T | undefined type. If such a field is still typed T, TypeScript reports an error.
Schema issues carry the env key
Each issue on a SchemaValidationFailed from getAs or @Envapt({ schema }) now has the env key first in its path, the same as on InvalidEnvValue. A zod issue on PORT had the path [] in v8. It's ['PORT'] now.
The message lists only the key, as in Validation failed for PORT., because an issue message can quote the value and the value can be a secret. v8 added the first issue's message after the key. If you parsed the reason from the message, read error.issues now.
Errors from a custom file source
This only matters if you wrote your own source with supportsFiles: true. In v8, an error thrown by its readFile was caught while the .env files loaded. envapt then skipped that file and every file after it in the cascade.
In v9 envapt doesn't catch it. Envapter.useSource throws it, and any later reload of the .env files throws it too.
Envapter.environment is a string
Envapter.environment returns a string like 'production' in v9. v8 returned a number, like 2 for production. Environment is now a plain object of strings, with Environment.Production holding 'production', so a comparison like Envapter.environment === Environment.Production keeps working.
Code that turned the number back into a name breaks:
// v8
console.log(Environment[Envapter.environment]); // 'Production'
// v9
console.log(Environment[Envapter.environment]); // undefined
console.log(Envapter.environment); // 'production'TypeScript reports the v9 Environment[...] line as an error. In JavaScript it logs undefined. Setting Envapter.environment to a number throws InvalidUserDefinedConfig. Rewrite a type annotation like Environment.Test as 'test'.
Renames and removals
TypeScript reports each of these in v9, so you can work through them from the compiler output.
| v8 | v9 |
|---|---|
Envapter.getUsing(key, token, fallback?) | Envapter.getAs(key, token, fallback?) |
Envapter.getWith(key, fn, fallback?) | Envapter.getAs(key, fn, fallback?), with the new converter shape |
Envapter.parse(key, schema, fallback?) | Envapter.getAs(key, schema, fallback?) |
Envapter.getNumber(key, fallback?) | Envapter.getAs(key, Converters.Number, fallback?), or Converters.Port and Converters.Integer for a port or a count |
Envapter.getBoolean(key, fallback?) | Envapter.getAs(key, Converters.Boolean, fallback?) |
Envapter.getBigInt(key, fallback?) | Envapter.getAs(key, Converters.BigInt, fallback?) |
@EnvStr(key, fallback?), from envapt or envapt/legacy | @Envapt(key, { fallback }) from the same entry |
@EnvNum, @EnvBool, @EnvTime, @EnvUrl, from envapt or envapt/legacy | @Envapt(key, { converter, fallback }) with Converters.Number, Boolean, Time, or Url |
Converters.Bigint | Converters.BigInt |
Converters.Regexp | Converters.RegExp |
String, Number, Boolean, BigInt as a converter | Converters.String, Converters.Number, Converters.Boolean, Converters.BigInt |
Envapter.getSymbol(key, fallback?), Converters.Symbol, a symbol fallback on @Envapt | Symbol.for(Envapter.get(key, 'app')), with a string fallback |
Envapter.resetProfiles() | Envapter.resetPaths() |
Envapter.load() | remove the call |
an instance member, like new Envapter().get(key) or config.isProduction | the static member, like Envapter.get(key) or Envapter.isProduction |
this.get(key) in a subclass's instance method | Config.get(key), called on your subclass |
EnvaptOptions | pass the options object to @Envapt inline |
CustomElementConverter<T> | ConverterFunction<T> |
ConverterFunction<T, string> | ConverterFunction<T> |
EnvaptErrorCodes.MissingEnvValue as a type | typeof EnvaptErrorCodes.MissingEnvValue, or 305 |
getUsing<T>(key, Converters.Json) typed as T | getAs<T>(key, Converters.Json) typed as T | undefined, or pass a fallback to get T |
Without TypeScript, some of these throw an envapt error at runtime:
String,Number,Boolean, orBigIntas a converter throwsInvalidUserDefinedConfig(302), with a message pointing to the matchingConverterstoken.- A symbol fallback throws
FallbackConverterTypeMismatch(104). Converters.Bigint,Converters.Regexp, andConverters.Symbolareundefinednow. A reader given one throwsInvalidCustomConverter(203). On@Envapt,converter: undefinedcounts as no converter, so the field reads the raw string with no error.
getAs accepts a token, a function, or a schema. Converters.BigInt and Converters.RegExp are now spelled like the JavaScript globals.
On Node, envapt binds FileSource when you import it, and binding a source loads the .env files, so load() already did nothing in v8. resetProfiles() became resetPaths() because it clears envPaths along with the profiles.
EnvaptErrorCodes is a plain object now, like Environment, so EnvaptErrorCodes[305] returns undefined. v9 removed InvalidFallbackType (102), InvalidConverterType (204), and PrimitiveCoercionFailed (205). Every other code kept its number.
Custom converters get a string
A custom converter passed to getWith or @Envapt ran on every read in v8. It got raw as string | undefined and the fallback as a second argument, so each one opened with a check for a missing value. In v9 envapt calls it only when the variable is set, with raw as a string, and applies the fallback itself.
// v8
const hosts = Envapter.getWith('ALLOWED_HOSTS', (raw, fallback) => (raw ? raw.split(',') : fallback), ['localhost']);const hosts = .('ALLOWED_HOSTS', () => .(','), ['localhost']);hosts is a string[]. It's ['localhost'] when ALLOWED_HOSTS is unset, and the converter never runs. If your converter returns undefined or null, envapt returns the fallback, so a custom converter can't return null as a real result anymore.
A decorated field with a custom converter and no fallback now needs a T | undefined type, because an unset variable gives it undefined without calling your function.
In Converters.array, envapt throws ArrayElementConversionFailed when an element converter returns null. v8 kept the null in the array.
Schemas go in schema
An arktype type is a function, and it's a Standard Schema too. v8 accepted one as the converter on @Envapt, or as the of in Converters.array. envapt called it like any converter and returned arktype's ArkErrors object as the value whenever validation failed.
v9 rejects a Standard Schema in both places. Passing one fails to compile. At runtime the decorator throws InvalidUserDefinedConfig, and the array throws InvalidArrayConverterType. Pass the schema to @Envapt as schema:
import { type } from 'arktype';
import { Envapt, Envapter } from 'envapt';
class Config extends Envapter {
@Envapt('PORT', { schema: type('string.integer.parse'), fallback: 3000 })
static accessor port: number;
}v8 accepted only an object as schema, and an arktype type is a function, so that line threw InvalidUserDefinedConfig in v8.
Also in v9
These don't break existing code:
getRequiredandgetRequiredAlltake a synchronous Standard Schema.- Each argument to
Envapter.requirecan be an ordered key list, which passes when any key in it has a value. Each placeholder inEnvapter.resolvetakes one too and uses the first key with a value. ENVIRONMENT_KEYSis exported. It lists the variables envapt checks to detect the environment, in order, for tools that need to read the same ones.- If a zod
.transformor.refinethrew during a read, Node crashed with an unhandled rejection. It no longer does, and withEnvapter.debug = 'warn', envapt logs the original error. getRequired,getRequiredAll, and a required decorated field returnnullfor aConverters.Jsonvalue ofnull, the same asgetAs. v8 threwMissingEnvValue.