Refine README framing around caller-side conversion and no-flattening#106
Conversation
There was a problem hiding this comment.
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
Resultas 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.
I modified one of them slightly. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
|
@DScheglov Let me know what you think of these changes. |
|
@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 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. |
Clarify the explanation of Result flattening and its implications.
Added a note about shorthand notation for Result.
| const result = try normalize(db.select(db.connect(), id)); | ||
|
|
||
| if (!result.ok) { | ||
| throw new ServerError(result.error, request, response); |
There was a problem hiding this comment.
this example mixes try with throw, is this intended?
There was a problem hiding this comment.
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.
Co-authored-by: Arthur Fiorette <47537704+arthurfiorette@users.noreply.github.com> Co-authored-by: Dmytro Shchehlov <Dmitry.Scheglov@gmail.com>
| ``` | ||
|
|
||
| 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. |
There was a problem hiding this comment.
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); }
});
}| 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. |
There was a problem hiding this comment.
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?"
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I suggest replacing this section with a new one: "Creating and Returning Result from Functions." This section should cover:
-
How to create and return
Result.okandResult.error: Provide a code example (e.g., password validation). -
Handling the result on the caller side: Include a practical example.
-
Explicit handling requirement: Declare that returning a
Resultforces the caller to handle both outcomes (either by handling the wholeResultor each case separatly). -
Readability trade-off: Note that this enforcement can sometimes lead to decreased code readability.
-
Coexistence with exceptions: Clarify that returning a
Resultdoes not replace or exclude throwing exceptions. A function can force the caller to handle some specific errors by returningResultwhile letting to be bubble up, by throwing exceptions. Referencefetchas implementing similar approach. Provide an example. -
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.
-
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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){}?
There was a problem hiding this comment.
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.
"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. |
@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, 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 |
This is both beautiful and terrifying. It reminds me of the 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. |
@Arlen22 it must be coverd by "try catch is not Enough"
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.
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 In general, if the result is consumed, it must be handled by the caller in one of two ways:
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 Ultimately, forcing proper handling must be a matter of Result-design. |
I have developed a simple one-step algorithm to decide whether to use a
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, 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 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. |
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 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 A similar situation occurs with unique indexes: we have a unique index for 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 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. |
|
For the most part the 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 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. |
That's expected — this approach lives mostly in private codebases, in domain logic that's a company's core know-how.
"My monadic cascade"... interesting. The proposal introduces a 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. |
|
|
||
| For further discussion, see [GitHub Issue #92](https://github.com/arthurfiorette/proposal-try-operator/issues/92). | ||
| ```ts | ||
| toChainMatch(try db.select(db.connect(), id)) |
There was a problem hiding this comment.
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.
|
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 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
Obviously the point of |
Exactly. I'll just use |
nope, it doesn't break a static analysis, everything perfectly works. |
|
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); }
);
} |
Actually, static analysis is doing its best here. It's a tricky issue, and TypeScript correctly highlighted it. |
Oh true.
Yeah, agreed. |
|
@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. |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Arthur Fiorette <47537704+arthurfiorette@users.noreply.github.com>
Summary
This PR revises the README’s argument around
Result-returning APIs and thetryoperator.What Changed
tryoperator more succinctly.