@devscast/config provides a batteries-included configuration loader for Node.js projects. It lets you:
- Load configuration from JSON, YAML, INI, or inline objects defined in code
- 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
- Use the typed
env()helper throughout your app for safe 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 { defineConfig } 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 } = defineConfig({
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 { defineConfig } from "@devscast/config";
const { config, env } = defineConfig({
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 { defineConfig } from "@devscast/config";
const { config, env } = defineConfig({
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 invokingdefineConfig, ideal for lightweight scripts.- You can still validate the derived values with Zod (or any other validator) before using them.
import { z } from "zod/mini";
import { defineConfig } from "@devscast/config";
const schema = z.object({
database: z.object({
host: z.string(),
port: z.number(),
}),
featureFlags: z.preprocess(
value => {
if (typeof value === "string") {
return value
.split(",")
.map(flag => flag.trim())
.filter(Boolean);
}
return value;
},
z.array(z.string())
),
});
const { config } = defineConfig({
schema,
env: { path: ".env" },
sources: {
database: { host: "%env(DB_HOST)%", port: "%env(number:DB_PORT)%" },
featureFlags: "%env(FEATURE_FLAGS)%",
},
});
console.log(config.database);- Inline objects remove the need for TypeScript config files while still allowing env interpolation.
- Typed placeholders resolve after
.envfiles load; use Zod preprocessors for shapes like comma-delimited lists.
- 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.
- Inline objects: placeholders work the same way; combine them with Zod preprocessors for complex shapes (arrays, URLs, etc.).
The env() helper throws when the variable is missing. Provide a default with env("PORT", { default: "3000" }) or switch to env.optional("PORT").
defineConfig 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.