Skip to main content
zerotal

Configuration

Zerotal separates three things: deployment state (env()), code-level structure (config/*.ts), and runtime access (config()). You only need all three when your config has genuine structure or conditional logic — simple apps can rely on env() alone.

Mental model

Think of configuration as a pipeline from the environment to your code:

.env / process env        config/*.ts files            anywhere in the app
──────────────────        ─────────────────            ───────────────────
   env("PORT")     ─────►  AppConfig({ ... })   ─────►  config("app.port")
  (raw strings)            (typed structure)            (typed dot-path read)
  • env() pulls a single value out of the environment, coercing it to the type of your fallback.
  • A config/<name>.ts file assembles those values into a typed object via a package helper (AppConfig, DatabaseConfig, …).
  • At boot, every config/*.ts file is loaded into the config store; config() reads from it by dot-path.

Tip — Reach for the next layer only when you need it. A value used in exactly one place can stay an env() call; promote it to a config file when it gains structure, defaults, or conditional logic.

The env helper

Read environment variables with optional type coercion and a fallback. The fallback's type decides how the raw string is coerced:

// config/app.ts (or anywhere)
import { env } from "zerotal";

const port = env("PORT", 3000); // number fallback → coerced to number
const debug = env("APP_DEBUG", false); // boolean fallback → "true"/"1" become true
const appName = env("APP_NAME", "Zerotal App"); // string fallback
const apiKey = env("API_KEY"); // no fallback → string | undefined

env() reads Bun.env at call time. Use it in config files, providers, and anywhere you need deployment-time values.

Note — When you need a value to be present, use requireEnv("APP_KEY") instead — it throws a ConfigError at boot if the variable is unset, rather than returning undefined.

Config files

Config files live in config/. Each file exports a typed object via a package helper:

// config/database.ts
import { DatabaseConfig } from "@zerotal/orm";
import { env } from "zerotal";

export default DatabaseConfig({
  url: env("DATABASE_URL", "sqlite://./database.sqlite"),
});
// config/session.ts
import { SessionConfig } from "@zerotal/session";
import { env } from "zerotal";

export default SessionConfig({
  driver: env("SESSION_DRIVER", "cookie") as "cookie" | "redis",
  secret: env("SESSION_SECRET", ""),
  lifetime: 86400,
  secure: env("APP_ENV") === "production",
});

Config helpers (DatabaseConfig, SessionConfig, etc.) are typed factory functions that give you autocomplete and catch typos. Some — like AppConfig — also fill in defaults for fields you omit.

The config helper

Access any loaded config value at runtime:

// in a provider, middleware, or controller
import { config } from "zerotal";

config("app.name"); // get — typed as string (see below)
config("app.name", "Zerotal"); // get with fallback — fallback must match the path's type
config.require("app.key"); // throws ConfigError if absent
config.set("app.debug", true); // override at runtime (useful in tests)
config.all(); // dump all loaded config as a flat record

Dot-notation maps to the config file path and the key within it. config('database.url') reads url from config/database.ts.

Noteconfig() resolves against the booted application's config store. When no app is booted (some test or script contexts), use config.safe("app.name", "fallback"), which returns the fallback instead of throwing.

Typed dot-paths

config() is type-aware. Each path resolves to the type declared in the owning package's *ConfigShape, with autocomplete on the path string:

// in application code
config("app.name"); // string
config("app.port"); // number
config("cache.ttl"); // number
config("app.throttle.maxAttempts"); // number — nested paths work too
config.set("app.debug", "yes"); // type error: expected boolean

This works exactly like the container's ContainerBindings: there's a ConfigRegistry interface that every config-owning package augments by namespace. Core registers app and health; each package registers its own next to its *ConfigShape:

// in a package's config registry declaration
declare module "zerotal" {
  interface ConfigRegistry {
    cache: CacheConfigShape;
  }
}

A namespace lights up as typed once its package is imported (which an app does by referencing its XConfig factory in config/<namespace>.ts). Paths outside any registered namespace still compile — they fall back to the untyped config(path: string) overload returning unknown, so dynamic access is never blocked.

Which layer do I use?

LayerWhat it doesReach for it when
env()Reads deployment state (env vars)Credentials, URLs, driver names — anything that changes per environment
config/*.tsAssembles code-level structure and logicMulti-field configs, conditional logic, code references (class constructors, strategy objects)
config()Runtime access to loaded configIn service providers, middleware, and application code that needs cross-cutting config values

The rule of thumb: read raw values with env(), shape and default them in a config/*.ts file, and read the shaped result anywhere with config().

How config loads at boot

Zerotal loads config eagerly when the application boots, before any provider registers:

  • During boot(), the app auto-discovers and imports every config/*.ts file, taking each file's default export.
  • Those exports are merged into the ConfigManager (bound in the container under the "config" key) before the provider onRegisteronBootingonBooted phases run.
  • A file that throws on import (for example, a missing required env var) is skipped during auto-discovery rather than crashing the whole boot.

Because every file is loaded as a normal module, a config file may import and read other modules at the top level — just avoid circular config imports between files.

Warning — Auto-discovery silently skips a config file that throws while importing. If a namespace seems to be missing all its values, check that the file imports cleanly and that its required env vars are set.

Overriding config in tests

// in a test setup file
import { config } from "zerotal";

beforeEach(() => {
  config.set("mail.driver", "log"); // force log driver in tests
  config.set("queue.driver", "sync");
});

Noteconfig.set() overrides in-memory only. .env is never modified.

Application-level config

Create config/app.ts for app-wide settings:

// config/app.ts
import { env } from "zerotal";
import { AppConfig } from "zerotal/config";

export default AppConfig({
  name: "Example",
  url: env("APP_URL", "http://localhost:3000"),
  key: env("APP_KEY", "changeme-in-production"),

  cors: {
    origin: env("CORS_ORIGIN", "*"),
    credentials: false,
  },

  throttle: {
    maxAttempts: 120,
    windowSeconds: 60,
  },

  secureHeaders: {
    frameOptions: "SAMEORIGIN",
  },
});

AppConfig() fills in sensible defaults for everything you omit (env, key, debug, url, port, locale, timezone, http3, health, cors, throttle, secureHeaders, and the conventions auto-discovery settings). Read any value with config('app.name'), config('app.cors.origin'), etc. The auto-discovery settings live under the conventions key — see Conventions.

Loading config explicitly

The CLI entry (zt.ts) can load config synchronously at the top level and inject it into the app, so it's available before anything boots:

// zt.ts
import { Application } from "zerotal";
import { configLoader } from "zerotal/config";

const config = configLoader("./config"); // sync — scans config/*.ts, safe at top level
config.validate(); // runs each file's optional validate() export

const app = Application.create({ config }); // …or app.useConfig(config)

configLoader(dir) returns a ConfigLoader with all(), get("dot.path", fallback), has(path), and validate(). A config file may export a named validate(config) function; ConfigLoader.validate() runs them all and throws on the first failure.

One source of truth. If config was passed to Application.create({ config }), a later useConfig(...) is ignored (create wins) — so the framework-managed zt.ts, which always calls useConfig(...), never conflicts with an app that prefers to pass config to create(). When neither is used, the app auto-discovers config/*.ts at boot as described above.

Environment files

Zerotal automatically loads .env in development. For production, set variables in your deployment platform.

Danger — Never commit .env to version control. It holds real secrets. Commit .env.example with safe defaults instead.

# .env.example  (committed — safe defaults, no secrets)
APP_NAME=My App
APP_ENV=local
APP_KEY=
DATABASE_URL=sqlite://./database.sqlite
SESSION_SECRET=
# .env  (gitignored — real values)
APP_KEY=base64:abcdef...
DATABASE_URL=mysql://user:pass@localhost:3306/mydb
SESSION_SECRET=super-secret

Use APP_ENV to differentiate behaviour:

// in application code
if (config("app.env") === "production") {
  // production-only logic
}

References

MemberSignatureDescription
envenv(key: string, fallback?: string | boolean | number)Read an env var, coercing to the fallback's type; string | undefined with no fallback.
requireEnvrequireEnv(key: string): stringRead a required env var; throws ConfigError if unset.
configconfig(path: string, fallback?): valueRead a config value by dot-path, optionally with a fallback.
config.requireconfig.require(path: string): valueRead a config value; throws ConfigError if absent or null.
config.setconfig.set(path: string, value): voidSet a config value at runtime (in-memory only).
config.allconfig.all(): Record<string, unknown>Return all loaded config as a record.
config.safeconfig.safe(path: string, fallback): valueRead without throwing when no app is booted; returns the fallback.
AppConfigAppConfig(options): AppConfigShapeBuild the app namespace config, filling defaults for omitted fields.
configLoaderconfigLoader(dir = "./config"): ConfigLoaderSynchronously load a config/ directory into a ConfigLoader.
ConfigLoader.getget(key: string, fallback?): valueDot-path read against the loaded map.
ConfigLoader.validatevalidate(): thisRun each file's optional validate(config) export, throwing on failure.

Next steps

  • Conventions — the auto-discovery settings under the conventions key.
  • Providers — where config() is most often read.
  • Application — how config is injected and loaded at boot.
  • Deployment — setting env vars in production.