From 6310f9c609976a453707a793f855d2f5dd8adae7 Mon Sep 17 00:00:00 2001 From: Timothy Peterson <33550033+tmrp@users.noreply.github.com> Date: Fri, 3 Apr 2026 09:55:18 +0200 Subject: [PATCH] fix(core): include extended env vars when skipValidation is true When `skipValidation: true`, `createEnv` was returning `runtimeEnv` directly, silently discarding variables contributed via `extends`. Fix by hoisting the `extendedObj` reduction above the skip guard and merging it with `runtimeEnv` before the early return, matching the behaviour of the normal validation path (runtimeEnv takes precedence). Adds three regression tests in smoke-zod4.test.ts covering: - single preset vars are accessible - runtimeEnv values override preset values - multiple preset vars are all accessible --- .../fix-skip-validation-extended-env.md | 10 +++ packages/core/src/index.ts | 9 +-- packages/core/test/smoke-zod4.test.ts | 69 +++++++++++++++++++ 3 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-skip-validation-extended-env.md diff --git a/.changeset/fix-skip-validation-extended-env.md b/.changeset/fix-skip-validation-extended-env.md new file mode 100644 index 00000000..3eee9ad0 --- /dev/null +++ b/.changeset/fix-skip-validation-extended-env.md @@ -0,0 +1,10 @@ +--- +"@t3-oss/env-core": patch +--- + +fix: include extended env vars when `skipValidation` is true + +Previously, calling `createEnv` with `skipValidation: true` returned only +`runtimeEnv`, silently dropping any variables contributed by the `extends` +array. Extended variables are now merged in (with `runtimeEnv` taking +precedence), matching the behaviour of the normal validation path. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 05418a86..71ee070a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -323,6 +323,10 @@ export function createEnv< } } + const extendedObj = (opts.extends ?? []).reduce((acc, curr) => { + return Object.assign(acc, curr); + }, {}); + const skip = !!opts.skipValidation; if (skip) { if (opts.extends) { @@ -332,7 +336,7 @@ export function createEnv< } // biome-ignore lint/suspicious/noExplicitAny: - return runtimeEnv as any; + return Object.assign(extendedObj, runtimeEnv) as any; } const _client = typeof opts.client === "object" ? opts.client : {}; @@ -386,9 +390,6 @@ export function createEnv< return prop === "__esModule" || prop === "$$typeof"; }; - const extendedObj = (opts.extends ?? []).reduce((acc, curr) => { - return Object.assign(acc, curr); - }, {}); const fullObj = Object.assign(extendedObj, parsed.value); const env = new Proxy(fullObj, { diff --git a/packages/core/test/smoke-zod4.test.ts b/packages/core/test/smoke-zod4.test.ts index 5b5e90ac..6e92c841 100644 --- a/packages/core/test/smoke-zod4.test.ts +++ b/packages/core/test/smoke-zod4.test.ts @@ -787,6 +787,75 @@ test("overriding preset env var", () => { expect(env.PRESET_ENV).toBe(123); }); +describe("skipValidation with extends", () => { + test("includes extended env vars when skipValidation is true", () => { + const processEnv = { + PRESET_ENV: "preset", + MY_VAR: "myvar", + }; + + const preset = createEnv({ + server: { PRESET_ENV: z.string() }, + runtimeEnv: processEnv, + }); + + const env = createEnv({ + server: { MY_VAR: z.string() }, + extends: [preset], + runtimeEnv: processEnv, + skipValidation: true, + }); + + expect(env.MY_VAR).toBe("myvar"); + expect(env.PRESET_ENV).toBe("preset"); + }); + + test("runtimeEnv values take precedence over extended env vars when skipValidation is true", () => { + const preset = createEnv({ + server: { SHARED_VAR: z.string() }, + runtimeEnv: { SHARED_VAR: "from-preset" }, + }); + + const env = createEnv({ + server: { SHARED_VAR: z.string() }, + extends: [preset], + runtimeEnv: { SHARED_VAR: "from-runtime" }, + skipValidation: true, + }); + + expect(env.SHARED_VAR).toBe("from-runtime"); + }); + + test("includes vars from multiple extended presets when skipValidation is true", () => { + const processEnv = { + PRESET_ENV1: "preset1", + PRESET_ENV2: "preset2", + MY_VAR: "myvar", + }; + + const preset1 = createEnv({ + server: { PRESET_ENV1: z.string() }, + runtimeEnv: processEnv, + }); + + const preset2 = createEnv({ + server: { PRESET_ENV2: z.string() }, + runtimeEnv: processEnv, + }); + + const env = createEnv({ + server: { MY_VAR: z.string() }, + extends: [preset1, preset2], + runtimeEnv: processEnv, + skipValidation: true, + }); + + expect(env.MY_VAR).toBe("myvar"); + expect(env.PRESET_ENV1).toBe("preset1"); + expect(env.PRESET_ENV2).toBe("preset2"); + }); +}); + test("with built-in preset", () => { process.env.UPLOADTHING_TOKEN = "token"; const env = createEnv({