Skip to content

Commit d2f3777

Browse files
committed
feat(dotenv): support typed prefix
1 parent f5b7796 commit d2f3777

File tree

4 files changed

+128
-21
lines changed

4 files changed

+128
-21
lines changed

README.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ const { config, env } = loadConfig({
5454
env: { path: path.join(process.cwd(), ".env") },
5555
sources: [
5656
path.join("config", "default.yaml"),
57-
{ path: path.join("config", `${env("APP_ENV", { default: "dev" })}.yaml`), optional: true },
57+
{ path: path.join("config", `${env("NODE_ENV", { default: "dev" })}.yaml`), optional: true },
5858
{ featureFlags: ["beta-search"] },
5959
],
6060
});
@@ -94,7 +94,7 @@ const { config, env } = loadConfig({
9494
schema,
9595
env: {
9696
path: ".env",
97-
knownKeys: ["APP_ENV", "DB_HOST", "DB_PORT"] as const,
97+
knownKeys: ["NODE_ENV", "DB_HOST", "DB_PORT"] as const,
9898
},
9999
});
100100
@@ -118,10 +118,10 @@ const schema = z.object({
118118
redisUrl: z.string().url(),
119119
});
120120
121-
const env = createEnvAccessor(["APP_ENV", "APP_PORT", "REDIS_URL"] as const);
121+
const env = createEnvAccessor(["NODE_ENV", "APP_PORT", "REDIS_URL"] as const);
122122
123123
const config = schema.parse({
124-
appEnv: env("APP_ENV", { default: "dev" }),
124+
appEnv: env("NODE_ENV", { default: "dev" }),
125125
port: Number(env("APP_PORT", { default: "3000" })),
126126
redisUrl: env("REDIS_URL"),
127127
});
@@ -161,6 +161,9 @@ const { config } = loadConfig({
161161
### Referencing environment variables
162162
163163
- **Text-based configs** (JSON, YAML, INI): use `%env(DB_HOST)%`
164+
- **Typed placeholders**: `%env(number:PORT)%`, `%env(boolean:FEATURE)%`, `%env(string:NAME)%`
165+
- When the entire value is a single placeholder, typed forms produce native values (number/boolean).
166+
- When used inside larger strings (e.g. `"http://%env(API_HOST)%/v1"`), placeholders are interpolated as text.
164167
- **TypeScript configs**: call `env("DB_HOST")`; the helper is available globally when the module is evaluated
165168
- For tighter autocomplete you can build a project-local accessor via `createEnvAccessor(["DB_HOST", "DB_PORT"] as const)`
166169
@@ -171,9 +174,9 @@ The `env()` helper throws when the variable is missing. Provide a default with `
171174
`loadConfig` automatically understands `.env` files when the `env` option is provided. The resolver honours the following precedence, mirroring Symfony's Dotenv component:
172175
173176
1. `.env` (or `.env.dist` when `.env` is missing)
174-
2. `.env.local` (skipped when `APP_ENV === "test"`)
175-
3. `.env.<APP_ENV>`
176-
4. `.env.<APP_ENV>.local`
177+
2. `.env.local` (skipped when `NODE_ENV === "test"`)
178+
3. `.env.<NODE_ENV>`
179+
4. `.env.<NODE_ENV>.local`
177180
178181
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.
179182

src/config.ts

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createRequire } from "module";
1+
import { createRequire } from "node:module";
22
import fs from "node:fs";
33
import path from "node:path";
44
import vm from "node:vm";
@@ -93,13 +93,23 @@ export class ConfigParseError extends ConfigError {
9393

9494
export class ConfigValidationError extends ConfigError {
9595
constructor(readonly issues: z.core.$ZodIssue[]) {
96-
super("Configuration validation failed");
96+
const formatPath = (path: Array<PropertyKey>) =>
97+
path.length ? path.map(seg => (typeof seg === "number" ? `[${seg}]` : String(seg))).join(".") : "<root>";
98+
99+
const details = issues.map(iss => `${formatPath(iss.path)}: ${iss.message}`).join("\n");
100+
101+
super(`Configuration validation failed:\n${details}`);
97102
this.name = "ConfigValidationError";
98103
}
99104
}
100105

101106
// Optimized constants / helpers (hoisted to avoid rework on hot paths)
102-
const ENV_PLACEHOLDER_REGEX = /%env\(([A-Z0-9_]+)\)%/gi;
107+
// Support optional type prefixes in placeholders: %env(type:VAR)%
108+
// - type can be one of: string | number | boolean (case-insensitive)
109+
// - When the entire value is a single placeholder, we can return a non-string (number/boolean)
110+
// - When embedded inside a larger string, we interpolate as a string
111+
const ENV_PLACEHOLDER_ANY = /%env\((?:(string|number|boolean):)?([A-Z0-9_]+)\)%/gi;
112+
const ENV_PLACEHOLDER_FULL = /^%env\((?:(string|number|boolean):)?([A-Z0-9_]+)\)%$/i;
103113

104114
const TS_COMPILER_OPTS: ts.TranspileOptions = {
105115
compilerOptions: {
@@ -286,12 +296,12 @@ function parseFile(source: FileSource, cwd: string, accessor: EnvAccessor<string
286296
switch (format) {
287297
case "json":
288298
return ensureObject(JSON.parse(text), targetPath);
299+
case "ts":
300+
return ensureObject(loadTsModule(targetPath, text, accessor), targetPath);
289301
case "yaml":
290302
return ensureObject(YAML.parse(text), targetPath);
291303
case "ini":
292304
return ensureObject(ini.parse(text), targetPath);
293-
case "ts":
294-
return ensureObject(loadTsModule(targetPath, text, accessor), targetPath);
295305
default:
296306
throw new ConfigParseError(`Unsupported configuration format: ${format}`, targetPath);
297307
}
@@ -352,7 +362,22 @@ function ensureObject(value: unknown, origin: string): Record<string, unknown> {
352362

353363
function resolvePlaceholders(value: unknown, accessor: EnvAccessor<string>): unknown {
354364
if (typeof value === "string") {
355-
return value.replace(ENV_PLACEHOLDER_REGEX, (_m, name: string) => accessor(name));
365+
// If the entire string is exactly one placeholder, allow non-string returns
366+
const full = value.match(ENV_PLACEHOLDER_FULL);
367+
if (full) {
368+
const [, rawType, name] = full as unknown as [string, string | undefined, string];
369+
const type = rawType ? rawType.toLowerCase() : undefined;
370+
const raw = accessor(name as string);
371+
return coerceEnvValue(raw, type);
372+
}
373+
374+
// Otherwise, interpolate placeholders inside the string
375+
return value.replace(ENV_PLACEHOLDER_ANY, (_m, rawType: string | undefined, name: string) => {
376+
const type = rawType ? rawType.toLowerCase() : undefined;
377+
const raw = accessor(name);
378+
const coerced = coerceEnvValue(raw, type);
379+
return String(coerced);
380+
});
356381
}
357382
if (Array.isArray(value)) {
358383
const out = new Array(value.length);
@@ -369,6 +394,27 @@ function resolvePlaceholders(value: unknown, accessor: EnvAccessor<string>): unk
369394
return value;
370395
}
371396

397+
function coerceEnvValue(raw: string, type?: string): unknown {
398+
switch (type) {
399+
case undefined:
400+
case "string":
401+
return raw;
402+
case "number": {
403+
const num = Number(raw);
404+
return num; // May be NaN; let schema validation catch invalid cases
405+
}
406+
case "boolean": {
407+
const norm = raw.trim().toLowerCase();
408+
if (norm === "true" || norm === "1" || norm === "yes" || norm === "y" || norm === "on") return true;
409+
if (norm === "false" || norm === "0" || norm === "no" || norm === "n" || norm === "off") return false;
410+
// Fallback: non-empty strings are truthy, empty is falsey
411+
return Boolean(norm);
412+
}
413+
default:
414+
return raw;
415+
}
416+
}
417+
372418
/**
373419
* mergeValues:
374420
* - arrays: replace with next (clone)

src/dotenv.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { spawnSync } from "child_process";
2-
import fs from "fs";
3-
import path from "path";
1+
import { spawnSync } from "node:child_process";
2+
import fs from "node:fs";
3+
import path from "node:path";
44

55
// Hoisted regex & small helpers (avoid recompiles in hot paths)
66
const RX_VARNAME_STICKY = /_?[A-Z][A-Z0-9_]*/y; // sticky var name
@@ -58,8 +58,8 @@ export default class Dotenv {
5858
private commandExpansionEnabled = false;
5959

6060
constructor(
61-
private envKey = "APP_ENV",
62-
private debugKey = "APP_DEBUG"
61+
private envKey = "NODE_ENV",
62+
private debugKey = "NODE_DEBUG"
6363
) {}
6464

6565
setProdEnvs(prodEnvs: string[]) {
@@ -78,9 +78,9 @@ export default class Dotenv {
7878
/**
7979
* Symfony-like semantics:
8080
* - load .env (or .env.dist if .env missing)
81-
* - infer APP_ENV (default 'dev') if missing
81+
* - infer NODE_ENV (default 'dev') if missing
8282
* - load .env.local unless in test env
83-
* - skip if APP_ENV == 'local'
83+
* - skip if NODE_ENV == 'local'
8484
* - load .env.$env
8585
* - load .env.$env.local
8686
*/
@@ -142,7 +142,7 @@ export default class Dotenv {
142142
this.loadEnv(p, k, defaultEnv, testEnvs, overrideExisting);
143143
}
144144

145-
// Compute APP_DEBUG
145+
// Compute NODE_DEBUG
146146
const dk = this.debugKey;
147147
const currentEnv = process.env[this.envKey] ?? defaultEnv;
148148
const defaultDebug = !this.prodEnvs.includes(currentEnv);

tests/config.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,3 +231,61 @@ describe("loadConfig", () => {
231231
expect(config.key).toBe("value");
232232
});
233233
});
234+
235+
describe("typed %env(...)% placeholders", () => {
236+
it("supports number placeholders (native type when standalone, string when embedded)", () => {
237+
process.env.PORT = "8080";
238+
239+
const schema = z.object({
240+
port: z.number(),
241+
url: z.string(),
242+
});
243+
244+
const { config } = loadConfig({
245+
schema,
246+
env: false,
247+
sources: [{ port: "%env(number:PORT)%", url: "http://localhost:%env(number:PORT)%" }],
248+
});
249+
250+
expect(config.port).toBe(8080);
251+
expect(typeof config.port).toBe("number");
252+
expect(config.url).toBe("http://localhost:8080");
253+
});
254+
255+
it("supports boolean placeholders (native type)", () => {
256+
process.env.FEATURE_ONE = "true";
257+
process.env.FEATURE_TWO = "0"; // falsey
258+
259+
const schema = z.object({
260+
featureOne: z.boolean(),
261+
featureTwo: z.boolean(),
262+
});
263+
264+
const { config } = loadConfig({
265+
schema,
266+
env: false,
267+
sources: [{ featureOne: "%env(boolean:FEATURE_ONE)%", featureTwo: "%env(boolean:FEATURE_TWO)%" }],
268+
});
269+
270+
expect(config.featureOne).toBe(true);
271+
expect(config.featureTwo).toBe(false);
272+
});
273+
274+
it("accepts explicit string type and interpolates inside larger strings", () => {
275+
process.env.NAME = "service";
276+
277+
const schema = z.object({
278+
name: z.string(),
279+
location: z.string(),
280+
});
281+
282+
const { config } = loadConfig({
283+
schema,
284+
env: false,
285+
sources: [{ name: "%env(string:NAME)%", location: "/srv/%env(string:NAME)%/data" }],
286+
});
287+
288+
expect(config.name).toBe("service");
289+
expect(config.location).toBe("/srv/service/data");
290+
});
291+
});

0 commit comments

Comments
 (0)