v9.1.0

Structuring your config

Read your app's variables once, in one typed config module that throws at boot on a bad deploy, then import the values from every other file.

You've read process.env.DATABASE_URL in whichever file needed it. Each file parsed the string its own way and picked its own default. If the variable was missing, the first request to touch the database crashed. And a test ran against whatever your laptop's .env.local held.

A config module puts every read in one file that runs when the app starts. Envapter does the parsing there, since each converter returns a typed value, and getRequiredAll checks every required key before it throws. The other files import the typed result.

One module reads your variables

src/config.ts
import { ,  } from 'envapt';

const  = .(
    {
        : .,
        : .,
        : .
    },
    'camelCase'
);

export const  = {
    ...,
    : .('CACHE_TTL', ., '5m'),
    : .('ALLOWED_ORIGINS', .(), []),
    : .
};

export type Config = typeof ;
type Config = {
    cacheTtl: number;
    allowedOrigins: string[];
    isProduction: boolean;
    databaseUrl: URL;
    port: number;
    sessionSecret: string;
}

Config shows what the rest of the app gets. port is a number, databaseUrl is a URL, and cacheTtl is a number of milliseconds, so no other file parses them again. The 'camelCase' argument turns DATABASE_URL into databaseUrl. Without it, the keys stay as you wrote them.

If any of the three required keys are missing, getRequiredAll throws one MissingEnvValue error that lists them all. If they're set but one can't convert, such as PORT=abc, it throws one InvalidEnvValue that lists every bad key.

cacheTtl and allowedOrigins take a fallback, the value the read returns when the variable is unset. A fallback also replaces a value that can't convert, without an error, so CACHE_TTL=5 minutes reads as the '5m' fallback. Give a variable a fallback when the app has a sensible value to run on without it. If a typo in it should stop the app, list it in getRequiredAll instead. ALLOWED_ORIGINS is split on commas, and isProduction reads ENVIRONMENT, ENV, NODE_ENV, or MODE.

The module has a cost. Any script, test, or tool that imports config.ts needs every required variable set first. A script that reads one or two values can call Envapter directly and skip the module.

Import it from your entry file

src/index.ts
import { config } from './config';
import { createServer } from './server';

createServer().listen(config.port);

config.ts runs before any module that imports it. Listing ./config first in the entry file also covers modules that do work when they load without importing config, such as one that opens a connection pool. Imports run in order, so a missing SESSION_SECRET throws before that module runs, and the process exits before anything opens a port.

If a value is set but can't convert, the uncaught error also prints its issues, and an issue can quote the bad value. Reading the error without leaking values has the catch block that logs only the keys.

Import config in every other file

src/db.ts
import { config } from './config';

export function connectionOptions() {
    return { connectionString: config.databaseUrl.href, ssl: config.isProduction };
}

config.databaseUrl arrives as a URL, with no parsing and no default in db.ts. Nothing stops a file from reading process.env or calling Envapter.get itself, so this is a habit to keep. A file that reads on its own parses the string again and picks its own default, and the two can drift apart. To read a different variable later, you change config.ts, then set the new name in each environment and in your test setup.

Grouping settings

Once several keys share a prefix, such as DATABASE_ and REDIS_, nested objects read better than one long list. Keep one getRequiredAll call, and group its keys when you build config:

src/config.ts
const  = .(
    {
        : .,
        : .,
        : .,
        : .
    },
    'camelCase'
);

export const  = {
    : { : ., : . },
    : { : . },
    : { : . }
};

Two getRequiredAll calls, one per group, throw separately. If DATABASE_URL and REDIS_URL are both missing, the first call throws, its error lists only DATABASE_URL, and you find REDIS_URL on the next deploy.

A config class instead

If your code is written around classes, declare each setting as a typed field with the @Envapt decorator:

src/Config.ts
export class  {
    @('DATABASE_URL', { : ., : true })
    static accessor : ;

    @('CACHE_TTL', { : ., : '5m' })
    static accessor : number;
}

Each field needs the accessor keyword, which Decorators explains. If your project sets experimentalDecorators, import from envapt/legacy and declare static readonly fields instead. A decorated field reads its variable the first time something accesses it. A missing DATABASE_URL then throws during the first request that touches Config.databaseUrl, which brings back the crash from the top of this page, and missing fields show up one at a time. The getRequiredAll module checks every required key when the app starts.

The class has one thing the module lacks. If something binds a new source after startup, such as a secrets refresh, a field reads its variable again on its next access. config keeps the values it read at load. Pick the class when you need that, and start with the module otherwise.

Tests

config.ts reads its variables when a test file first imports it, so the test values have to be bound before that. Bind them in a setup file:

tests/setup.ts
import { ,  } from 'envapt';

.(
    new ({
        : 'test',
        : 'postgres://app@localhost:5432/app',
        : '4000',
        : 'test-secret'
    })
);
vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({ test: { setupFiles: ['./tests/setup.ts'] } });

vitest runs each file in setupFiles before it imports a test file. PortableSource replaces the default source, so your shell and .env files stay out of the test. PORT: '4000' is a string, the same as a real environment variable, and config.port still comes out as the number 4000. To change one value in a single test, see Testing.

On Workers and in the browser

On these runtimes envapt reads nothing until you bind the object the platform gives you, so the bind goes at the top of config.ts, before the first read:

src/config.ts
import {  as  } from 'cloudflare:workers';
import { , ,  } from 'envapt';

.(new ());

export const  = .(
    { : ., : . },
    'camelCase'
);

A module that reads a value before the useSource line runs throws NoSourceBound, which is one more reason every file reads through config. On Workers, Cloudflare runs this module when you deploy, so a missing API_TOKEN fails the deploy. Cloudflare Workers covers that and the per-environment vars. In the browser the bind takes Vite's import.meta.env (Browser), and other edge runtimes follow the edge runtimes page.

Different values per environment

The keys stay the same in every environment, and the values come from wherever the source reads:

  • On Node, Bun, and Deno, .env.production loads in production on top of .env, and a variable set in the shell wins over both.
  • Secrets can come from a secrets manager, fetched at boot and bound with your .env files.
  • On Workers, each Wrangler environment sets its own vars and secrets.

On this page