From 3e85f43b08df5c90512603853f000cbec570844e Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Tue, 4 Aug 2026 08:51:01 +0000 Subject: [PATCH 1/2] Preserve sibling input evidence when Config.all fails --- .../test/ConfigAllInputEvidence.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 packages/effect/test/ConfigAllInputEvidence.test.ts diff --git a/packages/effect/test/ConfigAllInputEvidence.test.ts b/packages/effect/test/ConfigAllInputEvidence.test.ts new file mode 100644 index 00000000000..068de5ea881 --- /dev/null +++ b/packages/effect/test/ConfigAllInputEvidence.test.ts @@ -0,0 +1,34 @@ +import { assert, it } from "@effect/vitest" +import { Config, ConfigProvider, Effect, Result } from "effect" + +it.effect("preserves sibling input evidence when recovering an all failure", () => + Effect.gen(function*() { + const sourceError = new ConfigProvider.SourceError({ message: "source unavailable" }) + const provider = ConfigProvider.make((path) => { + if (path[0] === "failed") return Effect.fail(sourceError) + if (path[0] === "present") return Effect.succeed(ConfigProvider.makeValue("value")) + return Effect.succeed(undefined) + }) + const recovered = Config.all({ + failed: Config.string("failed"), + present: Config.string("present") + }).pipe(Config.orElse(() => Config.succeed({ failed: "recovered", present: "recovered" }))) + const fallback = { + recovered: { failed: "default", present: "default" }, + required: "default" + } + const config = Config.all({ + recovered, + required: Config.string("required") + }).pipe(Config.withDefault(fallback)) + + const result = yield* config.parse(provider).pipe( + Effect.mapError((error) => error.cause.message), + Effect.result + ) + assert.deepStrictEqual( + result, + Result.fail(`Expected string, got undefined + at ["required"]`) + ) + })) From bd118af387fc3958989662e966a1d3d3dad4fdfb Mon Sep 17 00:00:00 2001 From: Tim Smart Date: Tue, 4 Aug 2026 21:50:04 +0000 Subject: [PATCH 2/2] Preserve Config.all sibling input evidence --- .../preserve-config-all-input-evidence.md | 5 +++ packages/effect/src/Config.ts | 35 +++++++++++++++---- packages/effect/test/Config.test.ts | 28 +++++++++++++++ .../test/ConfigAllInputEvidence.test.ts | 34 ------------------ 4 files changed, 61 insertions(+), 41 deletions(-) create mode 100644 .changeset/preserve-config-all-input-evidence.md delete mode 100644 packages/effect/test/ConfigAllInputEvidence.test.ts diff --git a/.changeset/preserve-config-all-input-evidence.md b/.changeset/preserve-config-all-input-evidence.md new file mode 100644 index 00000000000..bd8b92ad8b2 --- /dev/null +++ b/.changeset/preserve-config-all-input-evidence.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Preserve sibling provider input evidence when `Config.all` evaluates a failing child. diff --git a/packages/effect/src/Config.ts b/packages/effect/src/Config.ts index 2d39dd3261c..5caddba3a43 100644 --- a/packages/effect/src/Config.ts +++ b/packages/effect/src/Config.ts @@ -17,6 +17,7 @@ import * as LogLevel_ from "./LogLevel.ts" import * as Option from "./Option.ts" import * as Predicate from "./Predicate.ts" import * as Rec from "./Record.ts" +import * as Result from "./Result.ts" import * as Schema from "./Schema.ts" import * as SchemaAST from "./SchemaAST.ts" import * as SchemaGetter from "./SchemaGetter.ts" @@ -414,14 +415,14 @@ export function all> | Record Effect.flatMapEager( - Effect.all(configs.map((config) => evaluateAt(config, provider, pathPrefix))), + Effect.all(configs.map((config) => Effect.result(evaluateAt(config, provider, pathPrefix)))), resolveArray ) ) as any } else { return make((provider, pathPrefix) => Effect.flatMapEager( - Effect.all(Rec.map(configs, (config) => evaluateAt(config, provider, pathPrefix))), + Effect.all(Rec.map(configs, (config) => Effect.result(evaluateAt(config, provider, pathPrefix)))), resolveRecord ) ) as any @@ -429,12 +430,19 @@ export function all> | Record> + results: ReadonlyArray, EvaluationFailure>> ): Effect.Effect>, EvaluationFailure> => { const values: Array = [] + let firstFailure: EvaluationFailure | undefined let firstAbsent: Absent | undefined let hasInput = false - for (const resolution of resolutions) { + for (const result of results) { + if (Result.isFailure(result)) { + firstFailure ??= result.failure + hasInput = hasInput || result.failure.hasInput + continue + } + const resolution = result.success if (resolution._tag === "Absent") { firstAbsent ??= resolution } else { @@ -442,6 +450,9 @@ const resolveArray = ( hasInput = hasInput || resolution.hasInput } } + if (firstFailure !== undefined) { + return Effect.fail(evaluationFailure(firstFailure.error, hasInput)) + } if (firstAbsent !== undefined) { return hasInput ? Effect.fail(evaluationFailure(firstAbsent.error, true)) : Effect.succeed(firstAbsent) } @@ -449,13 +460,20 @@ const resolveArray = ( } const resolveRecord = ( - resolutions: Record> + results: Record, EvaluationFailure>> ): Effect.Effect>, EvaluationFailure> => { const values: Record = {} + let firstFailure: EvaluationFailure | undefined let firstAbsent: Absent | undefined let hasInput = false - for (const key in resolutions) { - const resolution = resolutions[key] + for (const key in results) { + const result = results[key] + if (Result.isFailure(result)) { + firstFailure ??= result.failure + hasInput = hasInput || result.failure.hasInput + continue + } + const resolution = result.success if (resolution._tag === "Absent") { firstAbsent ??= resolution } else { @@ -463,6 +481,9 @@ const resolveRecord = ( hasInput = hasInput || resolution.hasInput } } + if (firstFailure !== undefined) { + return Effect.fail(evaluationFailure(firstFailure.error, hasInput)) + } if (firstAbsent !== undefined) { return hasInput ? Effect.fail(evaluationFailure(firstAbsent.error, true)) : Effect.succeed(firstAbsent) } diff --git a/packages/effect/test/Config.test.ts b/packages/effect/test/Config.test.ts index f48bfd1b75e..ddccd151da0 100644 --- a/packages/effect/test/Config.test.ts +++ b/packages/effect/test/Config.test.ts @@ -846,6 +846,34 @@ Expected "Infinity" | "-Infinity" | "NaN" ) })) + it.effect("preserves sibling input evidence when recovering an all failure", () => + Effect.gen(function*() { + const sourceError = new ConfigProvider.SourceError({ message: "source unavailable" }) + const provider = ConfigProvider.make((path) => { + if (path[0] === "failed") return Effect.fail(sourceError) + if (path[0] === "present") return Effect.succeed(ConfigProvider.makeValue("value")) + return Effect.succeed(undefined) + }) + const recovered = Config.all({ + failed: Config.string("failed"), + present: Config.string("present") + }).pipe(Config.orElse(() => Config.succeed({ failed: "recovered", present: "recovered" }))) + const config = Config.all({ + recovered, + required: Config.string("required") + }).pipe(Config.withDefault({ + recovered: { failed: "default", present: "default" }, + required: "default" + })) + const error = yield* config.parse(provider).pipe(Effect.flip) + + assert.strictEqual( + error.cause.message, + `Expected string + at ["required"]` + ) + })) + it.effect("does not invent provider input evidence after an initial source failure", () => Effect.gen(function*() { const sourceError = new ConfigProvider.SourceError({ message: "source unavailable" }) diff --git a/packages/effect/test/ConfigAllInputEvidence.test.ts b/packages/effect/test/ConfigAllInputEvidence.test.ts deleted file mode 100644 index 068de5ea881..00000000000 --- a/packages/effect/test/ConfigAllInputEvidence.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { assert, it } from "@effect/vitest" -import { Config, ConfigProvider, Effect, Result } from "effect" - -it.effect("preserves sibling input evidence when recovering an all failure", () => - Effect.gen(function*() { - const sourceError = new ConfigProvider.SourceError({ message: "source unavailable" }) - const provider = ConfigProvider.make((path) => { - if (path[0] === "failed") return Effect.fail(sourceError) - if (path[0] === "present") return Effect.succeed(ConfigProvider.makeValue("value")) - return Effect.succeed(undefined) - }) - const recovered = Config.all({ - failed: Config.string("failed"), - present: Config.string("present") - }).pipe(Config.orElse(() => Config.succeed({ failed: "recovered", present: "recovered" }))) - const fallback = { - recovered: { failed: "default", present: "default" }, - required: "default" - } - const config = Config.all({ - recovered, - required: Config.string("required") - }).pipe(Config.withDefault(fallback)) - - const result = yield* config.parse(provider).pipe( - Effect.mapError((error) => error.cause.message), - Effect.result - ) - assert.deepStrictEqual( - result, - Result.fail(`Expected string, got undefined - at ["required"]`) - ) - }))