-
-
Notifications
You must be signed in to change notification settings - Fork 4.8k
feat: add preserve-caught-error
rule
#19913
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
✅ Deploy Preview for docs-eslint ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
Not sure why, I am getting CI issues with my
Although I see MDN links being used in docs of some existing rules: |
f15c629
to
ee5a39c
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the pull request. Can you also add a type definition for the new rule to lib/types/rules.d.ts? This is a good example:
Lines 2447 to 2453 in bb370b8
"no-extend-native": Linter.RuleEntry< | |
[ | |
Partial<{ | |
exceptions: string[]; | |
}>, | |
] | |
>; |
After that, you can run node tools/update-rule-type-headers.js
locally to autogenerate the TSDoc comment from the rule metadata.
@fasttime Thanks for your help! As per you review, I have:
All CI checks are passing and the branch is up-to-date with upstream main. Please let me know if you see any more issues! |
I've also marked this rule as |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A lovely start! fasttime left some very good review comments around ESLint's infrastructure that I don't have a lot to add to. I also have some separate suggestions around the options and default behavior. Cheers!
lib/rules/preserve-caught-error.js
Outdated
properties: { | ||
customErrorTypes: { | ||
type: "array", | ||
items: { type: "string" }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
😄 #16540 is relevant in core, at long last!
The problem with an array of plain strings is that it's easy for users to accidentally use unrelated names that coincidentally match the strings. The strings just encode the raw name, not any other metadata such as location / package the targeted construct comes from.
For example, suppose a project has a SafeError
class:
// src/utils/SafeError.js
export class SafeError extends Error { /* ... */ }
It might configure the rule like:
"preserve-caught-errors": ["error", { customErrorTypes: ["SafeError"] }]
If a file happens to create its own SafeError
, then oops, the rule won't report:
class SafeError { /* ... */ }
export function doSomethingDangerous() {
// ...
try {
// ...
} catch (error) {
throw new SafeError(error);
}
}
If this option were a must-have for the rule, I would ask that we talk about adopting some kind of standard locator format - e.g. typescript-eslint's TypeOrValueSpecifier. But per my comment later in the file, I think we can omit this option for now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't feel strongly about this. @nzakas what do you think?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's remove it for now. We can always revisit later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good, I've removed the option for now.
We can revisit when we have something like TypeOrValueSpecifier implemented in core. @JoshuaKGoldberg Do we have an issue filed for it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not that I know of. I'm also not confident it would make sense in core, since core doesn't have any concept of types.
lib/rules/preserve-caught-error.js
Outdated
return ( | ||
throwStatement.argument.type === "NewExpression" && | ||
throwStatement.argument.callee.type === "Identifier" && | ||
allErrorTypes.has(throwStatement.argument.callee.name) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Bug] Speaking of shared helpers, there's no way for a core ESLint rule like this one to know what type a helper function throws:
"preserve-caught-errors": ["error", { customErrorTypes: ["SafeError"] }]
declare function throwSafeError(cause: Error): SafeError;
try {
// ...
} catch (error) {
throwSafeError(error);
}
See also my earlier comment on standalone strings not being ideal for these options.
Proposal: I think it'd be best to omit the customErrorTypes
logic for now. Instead, I think the rule should err on the side of permissiveness. If the catch
block passes its caught error as an argument to any call or new expression, the rule should consider the caught error as used.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the
catch
block passes its caught error as an argument to any call or new expression, the rule should consider the caught error as used.
I like this idea. 👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I also like this idea as this allows us to cover all types of cases in a generic way.
But, a couple of thoughts:
- Going with the above approach would mean cases like the following would be considered valid even though we're losing error context there. In other words, this would solve one problem (helper functions) but break the rule for multiple other important cases.
try {
doSomething();
} catch (err) {
if (err.code === "A") {
throw new Error("Type A", { cause: err });
}
throw new TypeError("Fallback error");
}
- I recently switched my approach to start with
ThrowStatement
listeners and track parentCatchClause
, as it allows reaching every throw statement without dealing with searching tonnes of possible JavaScript constructs as discussed in feat: addpreserve-caught-error
rule #19913 (comment). So in order to answer the question - "If the caught error has been used at least once", we'll still have to do the manual search which we were trying to avoid. (Maybe there is a better way I can't think of)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Proposal: I think it'd be best to omit the
customErrorTypes
logic for now. Instead, I think the rule should err on the side of permissiveness. If thecatch
block passes its caught error as an argument to any call or new expression, the rule should consider the caught error as used.
By this logic, we wouldn't be able to check the error type to make sure it allows the cause
option to be specified. Many custom errors, like Node.js' AssertionError
, don't provide a way to specify a cause
, and there is no point in reporting a violation if such an error is created without including the caught error. I think it's better to restrict the rule on reporting specific error types only.
try { doSomething(); } catch (err) { if (err.code === "A") { throw new Error("Type A", { cause: err }); } throw new TypeError("Fallback error"); }
I think the above code should be invalid because the thrown TypeError
has no cause
option.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For the sake of simplicity and getting a first version of this rule, let's keep it simple and just error for the built-in error types. I agree with @fasttime that throw new TypeError("Fallback error")
should be flagged as invalid.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree, this should work in the current state then. We can ignore helper functions for now.
@fasttime @JoshuaKGoldberg Thanks for the detailed reviews! I have made the following changes:
@JoshuaKGoldberg I've left some ideas on your comment. Please let me know what you think! PS: Adding this rule to |
Ok, I've made some changes to handle another case for throws that use a function's return value. It makes sure that such function accept the caught error as one of it's arguments. Added the following test cases. Valid: `try {
doSomething();
} catch (err) {
// passing the caught error directly
throw createExtraneousResultsError("test", err);
}`,
`try {
doSomething();
} catch (err) {
// passing the caught error as a property in an object
throw createExtraneousResultsError("test", { err });
}`,
`try {
doSomething();
} catch (err) {
// caught error passed with explicit property name
throw createExtraneousResultsError("test", { error: err });
}`, Invalid: /* 14. Throw uses a function's return value, but the function does not use the `cause` to construct an error. */
{
code: `try {
doSomething();
} catch (err) {
throw createExtraneousResultsError("Something went wrong");
}`,
errors: [
{
messageId: "missingCauseInFunction",
},
],
}, CI is green again, and I've rebased this on upstream |
f4dbd84
to
3b40bf8
Compare
{ | ||
code: `try { | ||
doSomething(); | ||
} catch (err) { | ||
throw createExtraneousResultsError("Something went wrong"); | ||
}`, | ||
errors: [ | ||
{ | ||
messageId: "missingCauseInFunction", | ||
}, | ||
], | ||
}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this case should be considered valid because createExtraneousResultsError
could return a different error type than those of built-in errors. When not sure, it's better not to report possible false positives.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Regardless of the error type, err
is never referenced, so it seems like it should be invalid?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The main problem is that not all errors support a cause. For example, in this code, the suggestion of using the caught error as a cause
doesn't apply:
import { AssertionError } from "node:assert";
function createAssertionError(message) {
return new AssertionError({ message });
}
try {
testSomething();
} catch (error) {
throw createAssertionError("Test failed");
}
DOMException
is another example of a common error that doesn't support a cause, as is SuppressedError
(a built-in global as of ES2026).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Understood. My point is that if error
is present, then I think it must be used to avoid a violation. This code could just as easily be rewritten as:
import { AssertionError } from "node:assert";
function createAssertionError(message) {
return new AssertionError({ message });
}
try {
testSomething();
} catch {
throw createAssertionError("Test failed");
}
In this case, it's clear the intent is to ignore the thrown error.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, I see. Sorry for the misunderstanding. Here's another example that should be clearer:
import { AssertionError } from "node:assert";
function createAssertionError(message) {
return new AssertionError({ message });
}
try {
testSomething();
} catch (error) {
if (error.code !== "ENOENT") {
throw createAssertionError("Test failed");
}
}
In this case the error error
is caught and used, but it seems legitimate not to reference it in the call expression in the throw
statement.
If an error is caught but not used, like in my example in #19913 (comment), then the no-unused-vars
should report it already (Playground link), so I think it's okay not to handle that case additionally in this rule.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right so in the current implementation:
- If there are no throws inside a catch block, that is a valid case.
- If there is a throw and it does not use the cause error, that's an invalid case. This could be a function or a direct instantiation of an Error. If the function creates an error that does not support
cause
property, the rule could be disabled for that line. - If the
cause
property is used, but it is not same as caught error, that's an invalid case. - If there are throw statements, and the caught error is not being accessed, that's an invalid case.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense generally, but I'm not sure if it's a good idea to report on generic function or constructor calls. That means users will have to use a disable comment each time an error that doesn't support a cause
is thrown in a catch block. If we want to enable the rule on arbitrary error types besides built-in errors, I think it would be better to use an inclusion/exclusion list to allow exceptions for certain error types with a central setting.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AggregateError
expects options as a third parameter, so this case should be valid:
try {
throw new Error("Original error");
} catch (error) {
throw new AggregateError([], "Lorem ipsum", { cause: error });
}
this should be invalid:
try {
throw new Error("Original error");
} catch (error) {
throw new AggregateError([], { cause: error });
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, I totally forgot about this. I've made some changes to handle the args differently specifically for AggregateError
, but if this is too common, we might have to implement a more intelligent way. Also added a test case.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For this case the rule is reporting a suggestion with syntax errors:
try {
doSomething();
} catch (error) {
throw new Error();
}
I think it's okay not to report a suggestion in this case. Otherwise, the suggestion should set an empty message (throw new Error("", { cause: error })
).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ahh, nice catch!
I've fixed this use case, and added the following test case:
{
code: `try {
} catch (err) {
{
throw new Error();
}
}`,
errors: [
{
messageId: "missingCause",
suggestions: [
{
messageId: "includeCause",
output: `try {
} catch (err) {
{
throw new Error("", { cause: err });
}
}`,
},
],
},
],
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, can we also add a test case for this code?
try {
} catch (err) {
throw new AggregateError();
}
d4231db
to
6986a62
Compare
|
||
Examples of **incorrect** code for this rule: | ||
|
||
```js |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
```js | |
```js | |
/* eslint presever-caught-error: "error" */ |
|
||
Examples of **correct** code for this rule: | ||
|
||
```js |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
```js | |
```js | |
/* eslint presever-caught-error: "error" */ |
@@ -0,0 +1,307 @@ | |||
/** |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right now we report the following which is a false positive:
try {
// ...
} catch (err) {
const opts = { cause: err }
throw new Error("msg", { ...opts }); // Error: There is no `cause` attached to the symptom error being thrown
// or throw new Error("msg", opts)
}
I think in such cases we can just ignore the node instead of reporting a false positive, because it might be a lot of work to detect if the object referred has a cause property or not.
Prerequisites checklist
What is the purpose of this pull request?
[ ] Documentation update
[ ] Bug fix (template)
[X] New rule (template)
[ ] Changes an existing rule (template)
[ ] Add autofix to a rule
[ ] Add a CLI option
[ ] Add something to the core
[ ] Other, please explain:
fixes #19844
What changes did you make?
I've done some work to implement an initial version of
preserve-caught-error
rule.On top of what my plugin already supports, I've made some improvements as discussed in #19844.
The rule now also:
cause
within aCatchClause
.Error
types that support thecause
option by default. Accepts a customErrorTypes option, that is a list of user-defined error types to extend what Error types to look for.docs/src/rules/preserve-caught-error.md
.Is there anything you'd like reviewers to focus on?
Happy to iterate and make any changes/fixes required :)