Decorators
Declare typed config as class fields with @Envapt, using accessor fields by default or the legacy decorators from envapt/legacy.
Without a config class, every file that reads PORT repeats the key, the converter, and the fallback, so the copies can drift apart. @Envapt replaces them with one declaration on a class field. Every file reads Config.port and gets the same typed value.
class {
@('PORT', { : ., : 3000 })
static accessor : number;
@('CACHE_TTL', { : ., : '15m' })
static accessor : number;
}
.cacheTtl;cacheTtl is a number of milliseconds even though its fallback is the string '15m', because the Time converter converts the fallback too. A decorated field uses the same keys, converters, and cache as getAs. A value you read once, in one place, needs no class, and Envapter.getAs is shorter there. The same declaration works on instance fields. If your project already sets experimentalDecorators, import from envapt/legacy instead (Legacy decorators).
Declaring decorated fields
Declare each field with the accessor keyword. A decorator on a plain field can only return a starting value for it. That value is computed once when the class is defined (for a static field) or each time you construct an instance. It still holds the old value after a later Envapter.useSource call or a change of environment. On a static field, a missing required value would also throw while the module loads. An accessor field gives the decorator a getter and a setter to replace, so envapt reads the variable on first access and makes assignment throw InvalidUserDefinedConfig (302). Because envapt reads on first access, a class can be defined before useSource runs on Workers or in the browser (Sources).
TypeScript enforces this. A plain field gives the decorator undefined, while an accessor decorator receives the accessor object.
class {
@Envapt('PORT', { converter: Converters.Port, fallback: 3000 }) static : number;
}Adding accessor fixes both errors. The cost is the keyword, plus a ! on instance fields. TypeScript reports TS2564 on accessor host: string until you write accessor host!: string. Static fields skip that check. Write no initializer on either form, since envapt supplies the value.
class {
@('HOST', { : 'localhost' })
accessor !: string;
@('SENTRY_DSN')
accessor !: string | undefined;
@('REGION', { : 'us-east-1' })
static accessor : string;
}sentryDsn has no options, so it is the raw string or undefined. This form needs no compiler flag with TypeScript 5.0 or later. Output from tsc runs on Node, Bun, and Deno. Bun and Deno also run a .ts file with accessor fields directly. On Workers, the browser, and edge runtimes, your bundler has to compile them. Compatibility covers each runtime.
Legacy decorators
Legacy decorators are TypeScript's older property decorators. They are called with (target, key), where modern ones receive (target, context). TypeScript emits and checks that call shape only when experimentalDecorators is on. Use the modern form unless your project already sets the flag.
| Modern | Legacy | |
|---|---|---|
| Import from | envapt | envapt/legacy |
| Static field | static accessor x: T | static readonly x: T |
| Instance field | accessor x!: T | declare readonly x: T |
tsconfig.json | nothing | "experimentalDecorators": true |
class {
@('PORT', { : ., : 3000 })
static readonly : number;
@('SESSION_TTL', { : ., : '15m' })
declare readonly : number;
}Converters still comes from envapt. Only the decorators come from envapt/legacy. sessionTtl uses declare because with useDefineForClassFields on (the default for ES2022 and later targets), tsc emits a plain instance field as a class field declaration. That gives every instance its own undefined that hides the getter. declare stops that emit. A declare static field reads undefined under tsc, so write plain static readonly. Neither form takes an initializer.
Options
The first argument is the variable name, or an ordered list of names. The first name that has a value wins. The second is an optional options object.
Prop
Type
If you combine required with fallback, combine schema with converter, or give schema a value that isn't a Standard Schema, the class throws InvalidUserDefinedConfig (302) when it is defined, before any field is read. A fallback that doesn't match the converter throws FallbackConverterTypeMismatch (104) on first access. With a fallback and no converter, envapt infers the converter from the fallback's type. A string, number, boolean, or bigint fallback converts as that type, and any other fallback is a compile error until you pass a converter. Converters lists the members of Converters and the time units.
class {
@('ALLOWED_ORIGINS', { : .({ : . }), : [] })
static accessor : [];
@('FEATURE_FLAGS', { : () => .('|').(() => .()), : [] })
static accessor : string[];
@('TRUSTED_IPS', { : () => new (.(',').()) })
static accessor : <string> | undefined;
}trustedIps ends in | undefined because it has no fallback and isn't required.
The field type is checked
Without a check, a field typed number could read as undefined at runtime when the value is missing. The declared type has to hold what the converter returns. A wider type compiles, but a narrower one fails with "field type must hold the converter output". The legacy decorators run the same check through EnvaptFieldDecorator, which envapt/legacy exports.
class {
@Envapt('SAMPLE_RATE', { converter: Converters.Float }) static accessor : number;
}SAMPLE_RATE has no fallback and isn't required, so its output is number | undefined. The field needs that union, or a fallback to remove it.
Required fields
required: true makes the field throw on its first access when the value is missing or can't convert. Required values explains both errors, MissingEnvValue (305) and InvalidEnvValue (210).
class {
@('DATABASE_URL', { : ., : true })
static accessor : ;
@('SIGNING_KEY', { : (: string) => new ().(), : true })
static accessor : ;
}Nothing reads a field until your code does, so a missing DATABASE_URL throws on the first request that touches Config.databaseUrl. To make that throw at startup, read each required field once when the app starts, for example void Config.databaseUrl;. Each field throws alone, so a deploy missing three variables takes three runs to find. getRequiredAll reports all three at once, but you pass it each key and converter again, which the class already declares.
Validating with a schema
A converter such as Converters.Port returns your fallback without an error when it can't convert a value. A schema checks the value against your rules. If it rejects the value, the read throws SchemaValidationFailed (208), or InvalidEnvValue (210) on a required field. A field that isn't required still throws. Pass schema for a Standard Schema validator. The field type is the schema's output, with | undefined unless you add required: true or a fallback.
class {
@('STRIPE_SECRET_KEY', { : .().('sk_'), : true })
static accessor : string;
}startsWith('sk_') runs on the raw value before stripeKey is ever a string. A schema in converter throws InvalidUserDefinedConfig (302). In a decorator lists the other schema mistakes.
One value per field
A decorated value is read on first access and cached per class and field. Every instance of a class reads the same cached value, so two instances of Config never differ. A static and an instance field with the same name have separate cached values.
The cached value clears when you call Envapter.useSource or change a setting that rebuilds the cache, such as Envapter.environment, and the next access reads again. In a test, useSource gives a field a different value, as Testing shows.
A decorated class doesn't need to extend anything. To call getAs and the other read methods on the class too, see Extending Envapter.
No environment source is bound
If you access a decorated field before any source is bound, it throws NoSourceBound (307) with the message "No environment source is bound". On Node, Bun, and Deno, every envapt entry binds FileSource when it loads, envapt/legacy included. On Workers, the browser, and edge runtimes, nothing is bound until Envapter.useSource runs, so access the field after that call. The error is described on Sources.