Skip to content

💥 preserve thrown values in Result.cause - #1229

Open
jrandolf wants to merge 7 commits into
thefrontside:v5from
jrandolf:jrandolf/error-unknown
Open

jrandolf wants to merge 7 commits into
thefrontside:v5from
jrandolf:jrandolf/error-unknown

Conversation

@jrandolf

@jrandolf jrandolf commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

Preserve non-Error thrown values in explicit results for v5, and rename the failure field as requested.

Approach

Use { ok: false, cause: unknown } for failed results. Err() retains the original value without ThrownValueError; runtime consumers and documentation use cause. #1264 adds the v4 alias and deprecates error.

Fix CI by naming the benchmark package to prevent npm's audit crash. Local tests, formatting, lint, type checks, and the Node test build pass.

@pkg-pr-new

pkg-pr-new Bot commented Aug 27, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/effection@1229

commit: a475ca9

@codspeed

codspeed Bot commented Aug 27, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 6 untouched benchmarks


Comparing jrandolf:jrandolf/error-unknown (a475ca9) with v4 (c623418)1

Open in CodSpeed

Footnotes

  1. No successful run was found on v5 (b1e1f5f) during the generation of this report, so v4 (c623418) was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩

@cowboyd

cowboyd commented Aug 27, 2026

Copy link
Copy Markdown
Member

Hey @jrandolf, Thanks for the PR!

This is definitely a breaking change. I'm not opposed to it, but I'm curious why you think it is lossy? All information is retained, and If you are capturing a result object in the first place then by definition you are already preventing code upwards on the stack from seeing the throw value. In order to re-expose the stack to the error, you are would already need to destructure via the .ok guard. It seems to me at the point of de-structuring, you could re-raise any thrown value at the same point.

export function unbox<T>(result: Result<T>): T {
  if (result.ok) {
    return result.value;
  } else {
    throw result.error instanceof ThrowValueError ? result.error.cause : result.error;
  }
}

Or is the problem that Effection itself does not do this when it re-throws an error? That would definitely be a problem

@jrandolf

jrandolf commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor Author

Yes. Effection itself rethrows the wrapper.

#1149 established Result.error: Error, added ThrownValueError, and added coverage requiring until(Promise.reject("error")) to expose that wrapper. The behavior is deliberate.

Changing unbox() would fix the paths that call it, including callcc, scope destruction, and task-group shutdown. Other runtime paths bypass it: Do, race, coroutine resumption, the task iterator, the task Promise adapter, and background error propagation all use the normalized result.error directly. Preserving Result.error: Error while restoring the original thrown value would require the same unwrapping rule at each of those boundaries.

This PR changes the common representation instead. Result.error becomes unknown, and Err() stores the exact cause, so explicit results and automatic propagation follow the same rule. The wrapper retains the original information in cause; the behavioral difference is that Effection changes the identity and observable type of the caught value.

SvelteKit shows why that matters. Its HttpError and Redirect are plain classes, and isHttpError() and isRedirect() recognize them with instanceof. Wrapping either value breaks those guards. Next.js does not have this problem because its redirect sentinel is an Error and already passes through unchanged.

@cowboyd

cowboyd commented Aug 28, 2026 •

Copy link
Copy Markdown
Member

Right. This could be a change that could be accepted as part of a 5.0 release, but since it breaks a public API, it cannot go into the v4 release series as-is. However, failure to unbox is a bug in v4 and should be fixed.

@cowboyd

cowboyd commented Aug 30, 2026

Copy link
Copy Markdown
Member

I think the path forward here is to start a v5 branch with this as the first change on it, and then add the unwrapping behavior to v4. Is that latter item something you'd be willing to take on?

@jrandolf

Copy link
Copy Markdown
Contributor Author

Let's do it!

@jrandolf

Copy link
Copy Markdown
Contributor Author

@cowboyd I opened #1230 with the v4-compatible fix: explicit Result.error values remain Error, while automatic operation, coroutine, task, race, halt, and Promise boundaries restore the original thrown value. The full 235-step suite, lint, formatting, and type checks pass. I left this PR unchanged for the future v5 branch.

@jrandolf

Copy link
Copy Markdown
Contributor Author

@cowboyd Will you create the v5 branch? I don't have permission for this.

@cowboyd

cowboyd commented Oct 31, 2026

Copy link
Copy Markdown
Member

@jrandolf done!

@jrandolf

Copy link
Copy Markdown
Contributor Author

@cowboyd This PR is ready!

@cowboyd

cowboyd commented Nov 1, 2026

Copy link
Copy Markdown
Member

As long as we're making a breaking change, would it make sense to change the name of the field toreason or cause and then, in v4 introduce the new field name as an alias for error and deprecate error?

@jrandolf jrandolf changed the title 🐛 preserve unknown error causes 💥 preserve thrown values in Result.reason Nov 2, 2026
@jrandolf

jrandolf commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Done

@jrandolf jrandolf changed the title 💥 preserve thrown values in Result.reason 💥 preserve thrown values in Result.cause Nov 2, 2026
Comment thread bench/package.json
@@ -1,4 +1,5 @@
{
"name": "effection-benchmarks",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. I reproduced the benchmark install failure locally: without a package name, npm's audit crashes with Cannot read properties of null (reading 'name').

Comment thread test/race.test.ts Resolved
@jrandolf

jrandolf commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor Author

As long as we're making a breaking change, would it make sense to change the name of the field toreason or cause and then, in v4 introduce the new field name as an alias for error and deprecate error?

I've thought about it and reason/cause can exist even when the Result is ok (or at least, that's the correct semantics). Considering we are doing a breaking change, I think it's reasonable to just change error because semantically it's wrong. Users should ALWAYS defend their errors using type discrimination in the first place.

But this is up to you.

This branch was successfully deployed

1 active deployment
Preview — a475ca90 Deployed Nov 2, 2026 by jrandolf via deploy-preview #1448
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants