@devscast/config provides a batteries-included configuration loader for Node.js projects. It lets you:
- Load configuration from JSON, YAML, INI, or native TypeScript modules
- Reference environment variables in text files with the
%env(FOO)%syntax - Bootstrap environment values from
.envfiles (including.env.local,.env.<env>,.env.<env>.local) - Validate the resulting configuration with a Zod v4 schema before your app starts
- Consume the same
env()helper inside TypeScript configuration files for typed access toprocess.env
npm install @devscast/config zod
@devscast/configtreats Zod v4 as a required peer dependency, so make sure it is present in your project. This package imports fromzod/miniinternally to keep bundles lean. If your schemas only rely on the features exposed by the mini build (objects, strings, numbers, enums, unions, coercion, effects, etc.), consider importingzfromzod/miniin your own code as well for consistent tree-shaking. Need YAML or INI parsing? Install the optional peers alongside the core package:npm install yaml ini
import path from "node:path";
import { z } from "zod/mini";
import { loadConfig } from "@devscast/config";
const schema = z.object({
database: z.object({
host: z.string(),
port: z.coerce.number(),
username: z.string(),
password: z.string(),
}),
featureFlags: z.array(z.string()).default([]),
});
const { config, env } = loadConfig({
schema,
cwd: process.cwd(),
env: { path: path.join(process.cwd(), ".env") },
sources: [
path.join("config", "default.yaml"),
{ path: path.join("config", `${env("NODE_ENV", { default: "dev" })}.yaml`), optional: true },
{ featureFlags: ["beta-search"] },
],
});
console.log(config.database.host);import path from "node:path";
import { loadConfig } from "@devscast/config";
const { config, env } = loadConfig({
schema,
env: true,
sources: [
path.join("config", "base.json"),
path.join("config", "defaults.yaml"),
{ path: path.join("secrets", "overrides.ini"), optional: true },
{ featureFlags: (env.optional("FEATURE_FLAGS") ?? "").split(",").filter(Boolean) },
],
});- String entries infer the format from the extension; optional INI/YAML support depends on the peer deps above.
- Inline objects in
sourcesare merged last, so they are useful for computed values or environment overrides.
import { loadConfig } from "@devscast/config";
const { config, env } = loadConfig({
schema,
env: {
path: ".env",
knownKeys: ["NODE_ENV", "DB_HOST", "DB_PORT"] as const,
},
});
export function createDatabaseUrl() {
return `postgres://${env("DB_HOST")}:${env("DB_PORT")}/app`;
}- Providing
knownKeysnarrows theenvaccessor typings, surfacing autocomplete within your app. - The accessor mirrors
process.envbut throws when a key is missing; switch toenv.optional("DB_HOST")when the variable is truly optional.
import { z } from "zod/mini";
import { createEnvAccessor } from "@devscast/config";
const schema = z.object({
appEnv: z.enum(["dev", "prod", "test"]).default("dev"),
port: z.coerce.number().int().min(1).max(65535).default(3000),
redisUrl: z.string().url(),
});
const env = createEnvAccessor(["NODE_ENV", "APP_PORT", "REDIS_URL"] as const);
const config = schema.parse({
appEnv: env("NODE_ENV", { default: "dev" }),
port: Number(env("APP_PORT", { default: "3000" })),
redisUrl: env("REDIS_URL"),
});createEnvAccessorgives you the same typed helper without invokingloadConfig, ideal for lightweight scripts.- You can still validate the derived values with Zod (or any other validator) before using them.
// config/services.ts
import type { EnvAccessor } from "@devscast/config";
export default ({ env }: { env: EnvAccessor<string> }) => ({
redis: {
host: env("REDIS_HOST"),
port: Number(env("REDIS_PORT", { default: "6379" })),
},
});
// loader
import path from "node:path";
import { loadConfig } from "@devscast/config";
const { config } = loadConfig({
schema,
sources: [
{ path: path.join("config", "services.ts"), format: "ts" },
],
});- TS sources run inside a sandbox with the same
envhelper, so you can compute complex structures at load time. - Returning a function lets you access the accessor argument explicitly; you can also export plain objects if no logic is needed.
- Text-based configs (JSON, YAML, INI): use
%env(DB_HOST)% - Typed placeholders:
%env(number:PORT)%,%env(boolean:FEATURE)%,%env(string:NAME)%- When the entire value is a single placeholder, typed forms produce native values (number/boolean).
- When used inside larger strings (e.g.
"http://%env(API_HOST)%/v1"), placeholders are interpolated as text.
- TypeScript configs: call
env("DB_HOST"); the helper is available globally when the module is evaluated- For tighter autocomplete you can build a project-local accessor via
createEnvAccessor(["DB_HOST", "DB_PORT"] as const)
- For tighter autocomplete you can build a project-local accessor via
The env() helper throws when the variable is missing. Provide a default with env("PORT", { default: "3000" }) or switch to env.optional("PORT").
loadConfig automatically understands .env files when the env option is provided. The resolver honours the following precedence, mirroring Symfony's Dotenv component:
.env(or.env.distwhen.envis missing).env.local(skipped whenNODE_ENV === "test").env.<NODE_ENV>.env.<NODE_ENV>.local
Local files always win over base files. The loaded keys are registered on the shared env accessor so they show up in editor autocomplete once your editor reloads types.
Command substitution via $(...) is now opt-in for .env files. By default these sequences are kept as literal strings. To re-enable shell execution, add a directive comment at the top of the file:
# @dotenv-expand-commands
SECRET_KEY=$(openssl rand -hex 32)Once the tag is present, all subsequent entries can use command expansion; omitting it keeps parsing side-effect free.
If a command exits with a non-zero status or otherwise fails, the parser now keeps the original $(...) literal so .env loading continues without interruption.