Skip to content

Refine README framing around caller-side conversion and no-flattening#106

Merged
arthurfiorette merged 16 commits into
mainfrom
arlen22/readme-work-1
Mar 16, 2026
Merged

Refine README framing around caller-side conversion and no-flattening#106
arthurfiorette merged 16 commits into
mainfrom
arlen22/readme-work-1

Conversation

@Arlen22

@Arlen22 Arlen22 commented Mar 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR revises the README’s argument around Result-returning APIs and the try operator.

What Changed

  • Simplified a lot of Copilot gobbledygook.
  • Tried to clarify our position on APIs returning Result.
  • Tried to explain the benefit of the try operator more succinctly.
  • Tried to point out how this is already available in async code.
  • Tried to allow the idea of returning Result while also arguing against it so people don't start campaigning for it.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR refines the README’s framing around Result-returning APIs vs caller-side exception-to-value conversion, aiming to more clearly position the try <expression> operator as a caller-controlled boundary and to clarify the intentional “no flattening” behavior.

Changes:

  • Reworked the “caller vs callee” discussion to focus on JS’s existing throw-based ecosystem and caller-chosen boundaries (with an async Promise analogy).
  • Moved and rewrote the “No Flattening” rationale to emphasize intentional nested Result as a boundary marker.
  • Tightened various README phrasing and headings/ToC entries (e.g., “Can be inlined” header).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment thread README.md Outdated
Comment thread README.md
Comment thread README.md Outdated
Comment thread README.md Outdated
I modified one of them slightly.

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@Arlen22

Arlen22 commented Mar 12, 2026

Copy link
Copy Markdown
Collaborator Author

@DScheglov Let me know what you think of these changes.

@Arlen22 Arlen22 marked this pull request as draft March 12, 2026 12:52
@Arlen22

Arlen22 commented Mar 12, 2026

Copy link
Copy Markdown
Collaborator Author

@arthurfiorette if you could take a look at this that would be great.

I've tried to clean up a lot of the explanations and make them more straightforward. Most of it was ChatGPT's fault, I think, so I tried to tighten up the logic a lot.

I reordered several sections and tried to simplify them, mostly trying to look at it from the perspective of someone reading the proposal for the first time. I've also minimized the emphasis on Result a good bit, while still maintaining that the try operator makes returning a Result much less necessary.

I've also removed several of the links to various past discussions, because most of them seem very drifting and convoluted and when I open them it's not immediately obvious how they relate. If someone opens a new issue about one of these I think we should probably compile a fresh response, but I also did my best to condense those explanations directly into the readme.

@Arlen22 Arlen22 marked this pull request as ready for review March 12, 2026 15:17
Arlen22 added 3 commits March 12, 2026 11:45
Clarify the explanation of Result flattening and its implications.
Added a note about shorthand notation for Result.

@arthurfiorette arthurfiorette left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

nice work

Comment thread README.md
Comment thread README.md Outdated
const result = try normalize(db.select(db.connect(), id));

if (!result.ok) {
throw new ServerError(result.error, request, response);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

this example mixes try with throw, is this intended?

@Arlen22 Arlen22 Mar 13, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, the idea is that there's upstream code that catches it, sends a generic error page to the client, logs the error, and does whatever else needs to happen. Some ORMs will also rollback open transactions when a promise rejects. It might not be the best representation of this though.

Comment thread README.md
Comment thread README.md
Comment thread README.md Outdated
Comment thread README.md Outdated
Comment thread README.md Outdated
Co-authored-by: Arthur Fiorette <47537704+arthurfiorette@users.noreply.github.com>
Co-authored-by: Dmytro Shchehlov <Dmitry.Scheglov@gmail.com>
Comment thread README.md
Comment thread README.md
Comment thread README.md Outdated
Comment thread README.md Outdated
Comment thread README.md Outdated
Comment thread README.md
Comment thread README.md Outdated
```

The `try` operator is designed to coexist with `throw`, not replace it. Following the [caller's approach](#callers-approach), functions typically throw and let callers decide how to handle errors. Returning `Result` can still make sense when forcing acknowledgement is more important than minimizing cross-boundary error-conversion boilerplate.
This is reminiscent of Go's `if err != nil { return err }`, even though Go's tuples and JavaScript `Result` objects are not otherwise the same abstraction. Sometimes that repetition is acceptable. Sometimes it is just redundant plumbing around behavior that would otherwise happen automatically.

@DScheglov DScheglov Mar 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The comparision with Go's approach is not correct.
Go uses the tuple without the monadic methods.
The monadic methods allows to avoid such noise.

The same example with correctly implemented Result:

function getUser(id, request, response) {
  return db.connect()
    .chain(conn => db.select(conn, id))
    .chain(normalize)
    .match({
      ok: (user) => response.send(JSON.stringify(user)).end(),
      error: (err) => {
        throw new ServerError(err, request, response);
      }
    });
}

The same with .unwrapOrThrow and without try-operator:

function getUser(id, request, response) {
  try {
    const conn = db.connect().unwrapOrThrow();
    const rawUser = db.select(conn, id).unwrapOrThrow();
    const user = normalize(rawUser).unwrapOrThrow();

    return response.send(JSON.stringify(user)).end();
  } catch (err) {
    throw new ServerError(err, request, response);
  }
}

And finally, with Result receiving the proper support for writing monadic computations in an imperative style:

function getUser(id, request, response) {
  return do {
    const conn = check db.connect();
    const rawUser = check db.select(conn, id);
    normalize(rawUser);
  }.match({
    ok: (value) => response.send(JSON.stringify(value)).end(),
    error: (err) => { throw new ServerError(err, request, response); }
  });
}

Comment thread README.md Outdated
This is reminiscent of Go's `if err != nil { return err }`, even though Go's tuples and JavaScript `Result` objects are not otherwise the same abstraction. Sometimes that repetition is acceptable. Sometimes it is just redundant plumbing around behavior that would otherwise happen automatically.

Mixed patterns (`Result` return + `throw`) may exist in practice, but this proposal treats them as unusual boundaries rather than the default composition path (see [No Flattening](#no-flattening)).
Nothing prevents developers from returning a `Result`, but the `try` operator solves the biggest reason why JavaScript developers often want to return a `Result` instead of throwing: the wonky and egregious code pathing and static-analysis breaking that is currently required.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PROOFS!

This is total nonsense. The try-operator in no way influences a developer's desire to return a Result, and it certainly doesn't resolve the primary reason for returning one. You literally state this yourselves in the very next paragraph.

What "egregious code pathing" are you talking about? You -- specifically you, @arthurfiorette and you @Arlen22 have invented a problem and are now using it as the motivation for this Proposal.

And what "breaking of static analysis" are you even referring to?

Shoving everything into an exception, creating a bunch of implicit dependencies on exception constructors or predicates, and then claiming that returning a Result breaks static analysis is absurd. It is exactly the opposite. Returning Results enables static analysis in cases where it was previously non-existent or fragile due to those mentioned implicit dependencies.

"What in heaven's name are you talking about?"

@DScheglov DScheglov Mar 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The reason to return a Result is the same as the reason fetch returns a promise that resolves with a Response (including unsuccessful ones) and rejects only with a network or request error.

And this reason is:

Distinguish expected domain failures from unexpected extradomain errors.

Accepting your arguments in this paragraph, we would need to create a new TC39 proposal to redevelop fetch, making it throw an exception in the case of unsuccessful responses.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I understood it to be in reference to using try ... catch, which currently motivates some developers to return a Result-like object instead of throwing.

@DScheglov DScheglov Mar 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@ziadkh0

The key question here is why or how exactly try ... catch motivates returning Result-like objects, rather than the mere fact that it does so.

Ok, even if we set the reason aside, it is better to state something like that:

If you are choosing between returning a Result and catching a thrown exception using a try-operator, it is better to throw an exception. This is a more flexible approach that allows you to change the place/moment of the exception handling, whereas returning a result requires handling it in one way or another immediately within the calling function.

Conversely, in cases where it is important to force the calling code to handle both successful and unsuccessful outcomes, returning a Result is the more appropriate solution.

Furthermore, it is entirely possible that the same function will throw certain errors as exceptions while returning others as unsuccesful results. We already see something similar in ECMAScript—specifically with the fetch function.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Accepting your arguments in this paragraph, we would need to create a new TC39 proposal to redevelop fetch, making it throw an exception in the case of unsuccessful responses.

I already added a paragraph with exactly that exception. I would absolutely hate if fetch did that.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yes, indeed, I lost some context: specifically, what exactly breaks the static analysis and what exactly leads to the "wonky and egregious code pathing."

Despite that, the key argument is still relevant:

The try operator doesn't resolve the primary reason for returning a Result. There is no research regarding that. In my information bubble, the main reason is type safety.

The "wonky and egregious code pathing" and possible static analysis issues are covered by analogues of Result.try or similar wrappers because, as you noted, JS has a huge ecosystem.

Overall, the arguments for the try-operator are quite valid, but they should be presented by directly comparing try...catch with this operator, without mentioning returning Result -- because it can confuse, a specially if we are talking it is written for someone new to the proposal.

@DScheglov DScheglov Mar 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I suggest replacing this section with a new one: "Creating and Returning Result from Functions." This section should cover:

  1. How to create and return Result.ok and Result.error: Provide a code example (e.g., password validation).

  2. Handling the result on the caller side: Include a practical example.

  3. Explicit handling requirement: Declare that returning a Result forces the caller to handle both outcomes (either by handling the whole Result or each case separatly).

  4. Readability trade-off: Note that this enforcement can sometimes lead to decreased code readability.

  5. Coexistence with exceptions: Clarify that returning a Result does not replace or exclude throwing exceptions. A function can force the caller to handle some specific errors by returningResult while letting to be bubble up, by throwing exceptions. Reference fetch as implementing similar approach. Provide an example.

  6. Summary of trade-offs: Conclude that returning a Result has both advantages and disadvantages; it is fully justified when errors are part of the contract (domain-specific), but it is a total overkill when dealing with unexpected, out-of-contract (out-of-domain) errors.

  7. Intent neutrality: The proposal is not aimed at forcing anyone to return Results or to stop returning Results if they already use this pattern.

This will lead directly into the No Result Flattening section.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Please also respond to the comment I posted in the main thread of this PR, at least to anything you haven't addressed in these last comments.

@Arlen22 Arlen22 Mar 14, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The try operator doesn't resolve the primary reason for returning a Result. There is no research regarding that. In my information bubble, the main reason is type safety.

Yeah, if you're trying to type an error, it would definitely make sense to return it as a Result. The other commonly used option is throwing a subclass of error. Out of curiousity, why not just do that and then use if(e instanceof MyDefinedError){}?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Out of curiousity, why not just do that and then use if(e instanceof MyDefinedError){}?

Assume, we have a user policy that validates incoming sign-up requests and returns one of these codes:

type UserPolicyErrorCode = 
  | "ERR_GEO_IP_MISMATCH"
  | "ERR_DISPOSABLE_EMAIL"
  | "ERR_KYC_FAILED"
  | "ERR_DUPLICATE_ENTITY"
  | "ERR_SIGNUP_RATE_EXCEEDED";

We also have a password policy that validates the same request and returns one of these codes:

type PasswordPolicyErrorCode = 
  | "ERR_PASSWORD_TOO_WEAK"
  | "ERR_PASSWORD_RECENTLY_USED"
  | "ERR_PASSWORD_TOO_SHORT"
  | "ERR_PASSWORD_LEAKED_DATA"
  | "ERR_PASSWORD_CONTAINS_USER_INFO";

Problem 1. How many exception classes do we need to cover all these cases?

a) Just one: PolicyException, SignUpException (but how does a policy know in which context it was invoked?), or BusinessException
b) Two: UserPolicyException and PasswordPolicyException
c) Ten — one for each case

Also consider the cost of evolving this approach. Say we need to attach a rateLimitResetDate to ERR_SIGNUP_RATE_EXCEEDED — we now need a new subclass with a constructor that enforces consistent state. How do we ensure every throw site is updated to pass that required field?

Problem 2. How does the signUp controller know which exception classes, codes, or both to handle?

The controller calls userService.signUp, which doesn't throw directly — but the functions it calls do. The policies are injected into userService because there are several interchangeable implementations. Do we need to inspect each one to know what might be thrown?

Problem 3. How can the signUp controller verify that its error handling is both complete and free of dead code?

Say we decide to replace ERR_GEO_IP_MISMATCH, ERR_DISPOSABLE_EMAIL, and ERR_KYC_FAILED with a single ERR_REQUEST_REVIEW_REQUIRED. We'll add the new code — but how do we know to remove the old ones?

Overhead 1. These errors don't need a stack trace — they'll be resolved by users who have no access to our code. For debugging, a stack is no more useful here than it would be for a regular incorrect return value. (If stacks were always necessary, we'd throw everything — after all, Error now has an official stack property in ES.) And stack capture isn't free: it's significantly slower than instantiating Result.error with a plain object.

Overhead 2. Exception classes require constructors. With CodedError<EC> and plain objects, constructors aren't needed at all — a simple factory function (or even an object literal) is enough. Every new error variant is just a new member of a union type, not a new class with boilerplate.

@DScheglov

Copy link
Copy Markdown
Contributor

@DScheglov Let me know what you think of these changes.

@Arlen22

"Caller vs. Callee: Using Result as a Return Type" is just a subjective assessment; nothing more. It should simply be removed without replacement.

If you provide the main points and we agree on them, I will do my best to write the corresponding section.

@Arlen22

Arlen22 commented Mar 14, 2026

Copy link
Copy Markdown
Collaborator Author

If you provide the main points and we agree on them, I will do my best to write the corresponding section.

@DScheglov essentially the existing situation, without the try operator, is as follows.

Normally when I'm writing code, I run into situations where I want to catch errors thrown by one specific function call, and almost always that function call is also returning a result. This creates a problem, because I have to use a try catch block to catch errors. This nearly always results in me creating a dedicated function call for that one command, just so I can wrap it in a try/catch without breaking the control flow.

When do you ever want to return a Result object to force the developer to handle an error instead of just throwing it? Throwing errors is kind of the definition of forcing developers to handle errors. If you return a result, you definitely aren't forcing them to do anything, and you're giving them an easy chance to ignore it. It really doesn't force anything, since an exception literally stops the program in its tracks without explicit handling. Of course, the try operator makes this easier, but it makes it explicitly the callers decision, instead being decided by the code they're calling.

But when it comes to normal JavaScript libraries and functionality, developer errors, input validation, and stuff like that where an error very often indicates an unexpected and wrong program state, it makes far more sense to throw than it does to return a Result, because throwing is very literally forcing the developer to handle it if they want to continue. Most of the JavaScript ecosystem operates with this mindset. For instance, JSON.parse throws for invalid input, and there are many other examples where invalid arguments cause an exception to throw instead of returning some kind of errored Result object.

That's why I say that the only reason you would return a Result is when the idea of "error" doesn't align with the JavaScript code context, such as HTTP status codes or financial transactions where you often still have to record the attempt. It wouldn't make sense to throw in those cases because there is so much context and detail surrounding it, and there is a big difference between "this failed to connect" and "this was refused".

On the specific case of fetch, how it handles the response ends up being the same regardless of the status code, so throwing for some status codes really doesn't make any sense at all. In the end it will still have a response body it has to process and recieve, headers it has to process, and a status code it has to read and provide to the user. It's up to the user to decide how to handle it from there, but from the perspective of what fetch does, it's all just status codes.

I grant that there are a few cases where it does make sense, certainly, but it is not the default in the Javascript ecosystem by any means, and for the most part it seems to be a stylistic preference of rust-adjacent software developers who find the control flow easier to reason about. I'm not denying that at all, and frankly the try operator brings some of that ease to the rest of the JavaScript ecosystem allowing you to reason better about error handling without needing your libraries to explicitly return a Result instead of throwing.

@Arlen22

Arlen22 commented Mar 14, 2026

Copy link
Copy Markdown
Collaborator Author
function getUser(id, request, response) {
  return db.connect()
    .chain(conn => db.select(conn, id))
    .chain(normalize)
    .match({
      ok: (user) => response.send(JSON.stringify(user)).end(),
      error: (err) => {
        throw new ServerError(err, request, response);
      }
    });
}

This is both beautiful and terrifying. It reminds me of the .then cascades that preceded the await keyword.

It's also outside the scope of this proposal and definitely needs to go in a subsequent proposal. I think the best we can hope for is that the decisions we make now aren't going to get in the way of implementing any of these styles in the future.

That's the biggest reason I'm trying to keep Result as minimal as possible and making a point about libraries not needing to return it. Making the argument against it now is not going to prevent someone else from making an argument for it in the future. Rather it assures as wide an audience as possible that this is a feature for library consumers to make their lives easier, not a feature intended for widespread adoption by library authors to make everyone else's lives more complicated.

@DScheglov

Copy link
Copy Markdown
Contributor

Normally when I'm writing code, I run into situations where I want to catch errors thrown by one specific function call, and almost always that function call is also returning a result. This creates a problem, because I have to use a try catch block to catch errors. This nearly always results in me creating a dedicated function call for that one command, just so I can wrap it in a try/catch without breaking the control flow.

@Arlen22 it must be coverd by "try catch is not Enough"


When do you ever want to return a Result object to force the developer to handle an error instead of just throwing it? Throwing errors is kind of the definition of forcing developers to handle errors. If you return a result, you definitely aren't forcing them to do anything, and you're giving them an easy chance to ignore it. It really doesn't force anything, since an exception literally stops the program in its tracks without explicit handling. Of course, the try operator makes this easier, but it makes it explicitly the callers decision, instead being decided by the code they're calling.

  1. "Throwing errors is kind of the definition of forcing developers to handle errors. "

I don't see how throwing forces someone to catch and handle an error. For example, earlier versions of Node.js did not terminate the process on unhandled exceptions or rejected promises. The change in that is what forced developers to pay more attention to error handling. In browsers, an exception thrown in an event handler will simply be ignored. It might be printed to the console, if only the console is not suppressed "in production for security reasons".

So, throwing is definitelly doesn't force to handle. The JS-runtime can force or not. And more then the process termination could be considered as handling by it self, allowing "superviser" to restart the "process". So, the developer even in case of process-termination could ignore exceptions.

  1. If you return a result, you definitely aren't forcing them to do anything, and you're giving them an easy chance to ignore it.

Yes and no at the same time. Like any value returned from a function, a "Result" can be ignored; this is true, and perhaps it is a matter for ESLint (or TSLint) to cover such cases. Nevertheless, ignoring it is the same as ignoring a user object returned from a getUserById() function. Receiving and handling the "Result" is, after all, one of the primary goals of calling the function, even if result is Result<void, Failure>

In general, if the result is consumed, it must be handled by the caller in one of two ways:

  1. Discriminate and handle at least one of the cases.
  2. Return (or pass to a callback) the entire "Result" object (In TS returning of Result is explicit handling because it affects the function signature, in JS it is less explicit, but it is still so).

Both options constitute "handling" the "Result"--not just the error case or the ok case, but the entire "Result" as a unit.

This is exactly why forcing the caller to discriminate before accessing .value or .error is so important. Consequently, Result.error(..).value and Result.ok(..).error must throw an assertion error. Throwing an exception when properties are accessed without prior discrimination forces developers to handle results correctly.

Ultimately, forcing proper handling must be a matter of Result-design.

@DScheglov

Copy link
Copy Markdown
Contributor

But when it comes to normal JavaScript libraries and functionality, developer errors, input validation, and stuff like that where an error very often indicates an unexpected and wrong program state, it makes far more sense to throw than it does to return a Result, because throwing is very literally forcing the developer to handle it if they want to continue. Most of the JavaScript ecosystem operates with this mindset. For instance, JSON.parse throws for invalid input, and there are many other examples where invalid arguments cause an exception to throw instead of returning some kind of errored Result object.

  • Throwing does not force error handling; see the comments on the previous paragraph.

  • The JS ecosystem is evolving. It started with a non-throwing approach, and we still see remnants of that today -- such as receiving undefined when accessing a non-existent field or getting NaN from +"abracadabra" instead of an exception. What, then, is the principal difference batween +"abracadabra" and JSON.parse? While 1 - '1'works due to legacy coercion, but the new features, like1 - 1n` throws an error. So, it depends. And it is definitely couldn't be generalized when we talk about the JS/TS-ecosystem.

  • Regarding input validation:
    It depends on the context. If I am parsing a request received from a client, I prefer to receive a Result. However, when validating database data against a schema, I prefer to throw.

I have developed a simple one-step algorithm to decide whether to use a Result or to throw:

  • Who will fix the error?
    • The client or user: Use a Result. Declare the errors in the contract and ensure the client or user has the information necessary to fix them.
    • Developers or DevOps: Throw an exception and report it to a service like Sentry.

Returing Result brings a new "error propagation" flow to the code, that is parallel to the exceptional one. Yes, it is not for free, yes we need to use monadic methods, if (res.ok) or even Do(function* () {}) to maintain this paralell flow, but we are confident that we respect the contract with client/user.

I am not convinced that this "price" is higher than the cost of creating numerous error classes and attempting to catch exceptions from specific functions. That approach is not inherently safe anyway; we still have to "recognize" the error. For example, if we replace JSON.parse with a parseRequest function, that might fail not just due to invalid input, but also because it failed to download a JSON schema. The controller function remains the same and we respond with "Unable to download JSON-schema from ...." to client, exposing the internal and maybe sensitive details to 3rd party and openning our servers to potential abuse.

Therefore, I don't believe we are paying more than is necessary.

We don't need to invent complicated approaches for filtering errors before sending them to Sentry. We already know the criteria: we simply report everything that we, as a development team, need to fix. We aren't consuming excessive quota, nor do we need a complicated algorithm to analyze Sentry issues. Just create a bug in Jira and let a developer investigate and fix it. Everything becomes straightforward.

@DScheglov

Copy link
Copy Markdown
Contributor

That's why I say that the only reason you would return a Result is when the idea of "error" doesn't align with the JavaScript code context, such as HTTP status codes or financial transactions where you often still have to record the attempt. It wouldn't make sense to throw in those cases because there is so much context and detail surrounding it, and there is a big difference between "this failed to connect" and "this was refused".

As a minor remark: Is it the only reason? We are paid to solve business problems. A significant, if not major, part of the code we write doesn't align with the "JavaScript code context".

As I mentioned above, returning a Result creates a new error propagation flow along side with exceptional one. While I previously wrote that these flows are parallel, they are not. They often intersect. Errors from a Result flow can be moved into the exceptional flow, and vice versa.

For example, imagine we are developing a Cloud DB Dashboard where users configure multiple database connections to view GOAT-metrics in GOAT-charts. Users explicitly specify connection parameters, so if a connection fails, we must display a clear error message explaining the problem. In this case, an error originates in our "deep core" within the exceptional flow, but late it is be caught, recognized, filtered, eventually converted into a Result.error({ code: ..., message: ... }) and presented to user.

A similar situation occurs with unique indexes: we have a unique index for email, isActive: true and need to return ERR_EMAIL_ALREADY_TAKEN, we are catching the exception, recognizing, filtering it by error code and constraint name, and then converting it into a Result.error to path through the Result flow and display to end-user.

If I were developing the corresponding db-client lib, I would still throw errors, even if my users eventually need to fix them. This is because I know that, my users -- the developers -- in most cases they will need to handle the fix themselves rather than their end-users.

However, if I were developing a schema validation library, I would likely provide both options: returning a Result or throwing an exception. I have already touched on this specific case.

And almost all places where I handle the Result.error-s contains code that throws in case of meeting unexpected/unknown error code, because translating it to client/user breaks the contract.

I want to say that the splitting of error-propagation flows is not about technology; it is about people.

@Arlen22

Arlen22 commented Mar 15, 2026

Copy link
Copy Markdown
Collaborator Author

For the most part the try operator handles both of our problems very well. I'm trying to be careful to not argue for using Result, while still allowing it as a coding pattern that people might use.

I think you've made a very strong case for returning Result as a potential use case, but most of your arguments hinge on adding additional functions to Result beyond just destructuring.

I've also never seen this in the wild, so I consider it more niche. I'm trying to make the case to the wider JavaScript community based on the coding conventions I normally see.

In the current state of this proposal, I think the best I can give you is to make sure that Result stays as small as possible, and the try operator stays as simple as possible, so that you and others still have the ability to augment it for yourself or propose additional methods and helpers in future proposals.

I would expect your way of handling errors to be mostly a user-land convention where you as the end-developer are bringing together various libraries into a custom solution. In that case, I'm sure you deal with many libraries that still throw errors, so the try operator will still benefit you a lot, and if you want to augment the result with all your monadic methods, you can easily wrap the outer try expression in an additional augment helper that just converts it from a standard result to whatever you're using.

If you are writing libraries to publish on NPM, I am hoping that you would use Result sparingly, if at all. Expecting users of your library to use your monadic cascade seems a bit of a stretch since they might have their own preferences for handling errors.

And yes, that does mean I'm going to argue against using it, since I don't want people to think that this introduces some grand new way of composing error handling, or that they should suddenly start returning Result instead of throwing.

Most people see shiny toy and want to play with it, whereas people like you immediately see where the tool can be applied with greatest effect. Me arguing against it won't prevent you from seeing the benefits it can provide, but it will hopefully prevent others from turning this into a drive to only return Result instead of throwing. Obviously you're not a fan of that either, as you just described! But that's why I think I have to argue against it.

Because most of the existing ecosystem just throws and moves on, the point of this proposal is to make that easier to consume and handle, not change how people throw errors.

@DScheglov

Copy link
Copy Markdown
Contributor

@Arlen22

I've also never seen this in the wild, so I consider it more niche.

That's expected — this approach lives mostly in private codebases, in domain logic that's a company's core know-how.

Expecting users of your library to use your monadic cascade seems a bit of a stretch since they might have their own preferences for handling errors.

"My monadic cascade"... interesting.

The proposal introduces a Result type, that's requires a "Prior Art" section. You must demonstrate how JS/TS developers currently solve these problems by surveying effect, fp-ts, and neverthrow at a minimum. Additionally, include Result API from Rust, C#/Java, and C++ (std::expected), alongside Go's error/value tuples. Finally, argue why the proposed Result API should closely align with Go's tuple-based approach.

I previously pointed out that the main issue with this proposal is that it attempts to introduce a solution far more sweeping and complex than its original feature. It is a classic case of putting the cart before the horse.

Comment thread README.md

For further discussion, see [GitHub Issue #92](https://github.com/arthurfiorette/proposal-try-operator/issues/92).
```ts
toChainMatch(try db.select(db.connect(), id))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What do you think this function toChainMatch returns?

It returns a Result<T> monadic type that is slightly more complex than the Result type from neverthrow.

The equivalent example using neverthrow looks like this:

import { fromThrowable, ok, err } from "neverthrow";

const getUser = (id: string) => 
  fromThrowable(() => db.select(db.connect(), id))()
   .andThen(user => user ? ok(user) : err("NOT_FOUND"))
   .andThen(fromThrowable(normalize))
   .match(
      (user) => response.send(JSON.stringify(user)).end(),
      (err) => { throw new ServerError(err); }
  );

If the proposal requires developers to invent such "helper" functions, the proposed Result API is too poor.
And actually the need for a try operator becomes far from obvious.

@Arlen22

Arlen22 commented Mar 15, 2026

Copy link
Copy Markdown
Collaborator Author

I think you're attaching a bunch of ideas to it that we have no intention of adding. We're simply providing the minimum required solution for the existing feature we're proposing, nothing more than that. All of these other methods and helper functions can be added later or simply created yourself.

Since as you said, most of what you're referring to lives in end product code bases, it is more than expected that I would not be proposing it be implemented across the whole of JavaScript. You can write the simplest of all helper functions to convert a try Result into whatever Result class your own error handling is using.

That's literally the entire point of the try operator. It's just an expression so you can use it as an argument to another function with absolutely no problems, and without having to use callbacks. The result just gets passed to the function and the function returns whatever you want it to.

function myResultHelper([ok, err, val]){
  return MyResult(ok, err, val);
}
myResultHelper(try anything())
  .chain(whatever)

which removes the callback required in your example from neverthrow

import { fromThrowable, ok, err } from "neverthrow";

const getUser = (id: string) => 
  fromThrowable(() => db.select(db.connect(), id))()
   //           ^^^ callback breaking static analysis
   .andThen(user => user ? ok(user) : err("NOT_FOUND"))
   .andThen(fromThrowable(normalize))
   .match(
      (user) => response.send(JSON.stringify(user)).end(),
      (err) => { throw new ServerError(err); }
  );

Obviously the point of fromThrowable is that it's creating a nonthrowable function. So it is still slightly different but I think my point stands.

@DScheglov

Copy link
Copy Markdown
Contributor

That's literally the entire point of the try operator. It's just an expression so you can use it as an argument to another function with absolutely no problems, and without having to use callbacks. The result just gets passed to the function and the function returns whatever you want it to.

function myResultHelper([ok, err, val]){
  return MyResult(ok, err, val);
}
myResultHelper(try anything())
  .chain(whatever)

Exactly. I'll just use MyResult.try and won't need the try-operator or proposed minimalistic Result. Most Result libraries already export a similar function.

@DScheglov

Copy link
Copy Markdown
Contributor
  fromThrowable(() => db.select(db.connect(), id))()
   //           ^^^ callback breaking static analysis

nope, it doesn't break a static analysis, everything perfectly works.
You can check in TS Playground

@Arlen22

Arlen22 commented Mar 15, 2026

Copy link
Copy Markdown
Collaborator Author
  fromThrowable(() => db.select(db.connect(), id))()
   //           ^^^ callback breaking static analysis

nope, it doesn't break a static analysis, everything perfectly works. You can check in TS Playground

Here's the TS Playground that breaks

@DScheglov

Copy link
Copy Markdown
Contributor
  fromThrowable(() => db.select(db.connect(), id))()
   //           ^^^ callback breaking static analysis

nope, it doesn't break a static analysis, everything perfectly works. You can check in TS Playground

Here's the TS Playground that breaks

ok )

They have workaround for that:

const getUser = (test:{id: string | number}) => {
  if(typeof test.id !== "string") return;

  fromThrowable((id: string) => db.select(db.connect(), id))(test.id)
   .andThen(user => user ? ok(user) : err("NOT_FOUND"))
   .andThen(fromThrowable(normalize))
   .match(
      (user) => response.send(JSON.stringify(user)).end(),
      (err) => { throw new ServerError(err); }
  );
}

@DScheglov

Copy link
Copy Markdown
Contributor

Here's the TS Playground that breaks

@Arlen22

Actually, static analysis is doing its best here. It's a tricky issue, and TypeScript correctly highlighted it.

@Arlen22

Arlen22 commented Mar 15, 2026

Copy link
Copy Markdown
Collaborator Author

ok )

They have workaround for that:

Oh true.

Actually, static analysis is doing its best here. It's a tricky issue, and TypeScript correctly highlighted it.

Yeah, agreed.

@Arlen22

Arlen22 commented Mar 15, 2026

Copy link
Copy Markdown
Collaborator Author

@arthurfiorette I think this is about as close as I'm going to get it for now. I'd say you can merge this since I'd say this is vastly improved despite the remaining objections, though I'm not sure I can entirely solve all of it for now. I'd say keep the branch for now in case I want to make more changes.

Comment thread README.md Outdated
Comment thread README.md Outdated
Comment thread README.md Outdated
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Arthur Fiorette <47537704+arthurfiorette@users.noreply.github.com>
@arthurfiorette arthurfiorette merged commit a01aaf0 into main Mar 16, 2026
2 checks passed
@arthurfiorette arthurfiorette deleted the arlen22/readme-work-1 branch March 16, 2026 14:09
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.

5 participants