Skip to content

Rule proposal: no-unnecessary-coercion #8515

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

Open
6 tasks done
skondrashov opened this issue Feb 20, 2024 · 16 comments · May be fixed by #10182
Open
6 tasks done

Rule proposal: no-unnecessary-coercion #8515

skondrashov opened this issue Feb 20, 2024 · 16 comments · May be fixed by #10182
Labels
accepting prs Go ahead, send a pull request that resolves this issue enhancement: new plugin rule New rule request for eslint-plugin package: eslint-plugin Issues related to @typescript-eslint/eslint-plugin

Comments

@skondrashov
Copy link

Before You File a Proposal Please Confirm You Have Done The Following...

My proposal is suitable for this project

  • My proposal specifically checks TypeScript syntax, or it proposes a check that requires type information to be accurate.
  • My proposal is not a "formatting rule"; meaning it does not just enforce how code is formatted (whitespace, brace placement, etc).
  • I believe my proposal would be useful to the broader TypeScript community (meaning it is not a niche proposal).

Description

I'd like String(something), something.toString(), and perhaps something as string to show an error if something is a string already, but to be allowed if it's a string | number or even a string | number | undefined. This is similar to the proposal at #6385 but it appears there're some issues with doing this with numbers that I don't think applies to strings.

In my case, I would like to do this because I am working with a library that exports a string | number | undefined, but I believe their type is incorrect, and I would like to essentially be notified that I can remove my casts if the type changes and becomes a string instead, and I believe similar use cases would not be exceeding rare, where one might be able to catch useless casts after updating one type somewhere, whether in a library or in their own type definitions. My feeling is that if this were turned on it would trigger errors in more than a couple of codebases. In reference to the similar issue for numbers, I do support the implementation of that as well for similar reasons.

Fail Cases

let replace = 'me';
replace = String(replace)

Pass Cases

let replace: string | number = 'me';
replace = String(replace)

Additional Info

This is also similar to eslint's https://eslint.org/docs/latest/rules/no-extra-boolean-cast, which I'm actually not sure how they achieve without typescript, but that rule makes a lot of sense to me as well.

@skondrashov skondrashov added enhancement: new plugin rule New rule request for eslint-plugin package: eslint-plugin Issues related to @typescript-eslint/eslint-plugin triage Waiting for team members to take a look labels Feb 20, 2024
@bradzacher
Copy link
Member

This is also similar to eslint's eslint.org/docs/latest/rules/no-extra-boolean-cast, which I'm actually not sure how they achieve without typescript, but that rule makes a lot of sense to me as well.

That rule exists just to do "if the Boolean() call is within a conditional test - error". It doesn't need types because it doesn't check the value passed at all.

A similar rule for String would be "if the String() call is within a sum with a string - error" (eg String(x) + '' would always error).

@bradzacher
Copy link
Member

but it appears there're some issues with doing this with numbers that I don't think applies to strings

I'm not sure what "issues" you're referring to.
But IMO the same arguments apply here. EG similar to what is mentioned in #6385 (comment) -- step 1 of the string coercion algorithm is "if string return" - so there's near zero perf hit from an unnecessary String() call - it's just a stylistic thing.

Otherwise our big argument was that it's not super common to use String()/Number()/Boolean() in modern TS, let alone having provably useless calls - so a rule warning against unnecessary calls isn't super high-value. Just not sure if people would find value in such a rule.

@bradzacher bradzacher added evaluating community engagement we're looking for community engagement on this issue to show that this problem is widely important and removed triage Waiting for team members to take a look labels Feb 20, 2024
@skondrashov
Copy link
Author

Thank you for the clarification on the Boolean rule! I do think then that something like this could cover !!, Boolean(), and as boolean casts of booleans as well.

The issue I was referring to was that for numbers, Number() has problems if it takes things like strings of spaces, so parseInt() is a better idea, and since parseInt() does not accept a string the rule is unnecessary. On the other hand for strings, String() and .toString() both work with string | number and I don't think they have any weird issues for numbers, at least for integers. For booleans as well, the dynamic casts react to many types intuitively.

I don't have a sense for how common it is to do casts to strings or otherwise. I feel like the rule is nice for as string/as number/as boolean as well, and I feel like it must be quite common for at least one kind of cast, whether it's JS or TS, to happen at the interface with form elements. It seems like it's easy for this problem to happen when a type changes from string | number to string, or from any broad type to a specific type and you don't realize you were doing a cast somewhere, and it's unambiguous that it's wrong to cast a thing to its own type too I think. As a user I kind of expect a linter to lean towards detecting things that are very obviously wrong rather than things that are very common (though "very harmful" makes sense as a metric too, and I agree that these mistakes aren't particularly harmful). Makes sense that you'd want community support, but I'd be happy to try the implementation myself too if it's about managing what you spend time on.

@JoshuaKGoldberg
Copy link
Member

Aside: this is a good example of why I'm real strict about separating "type assertion" and "cast" as terms. This issue is correct and talking about casting. But a lot of TS devs refer to type assertions as casting as well. They're not the same!

(ignore me, just ranting some contextual information)

@skondrashov
Copy link
Author

In my head I kind of use "dynamic casting" for String() and "static casting" for as string, but yeah very important to distinguish them! I feel like this rule could work for both which furthers the confusion but yeah.

@skondrashov
Copy link
Author

Another case occured to me that is in-line with my feelings on this, and apparently is already covered by another rule:
https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/docs/rules/no-unnecessary-condition.md

From my perspective, checking to see if something is already guaranteed to be a string when it is cast to or asserted to be a string is similar to checking if something is already guaranteed not to be undefined or null when used, for instance, with the optional chaining (?.) operator. I understand that branching conditionally on the type of a value is a thing that is yet again different from both casting a value and asserting a value's type, but in practice they are all ways for a user to get typescript to believe that a variable is a certain type, and I think linting rules are helpful which let a user know that typescript already believes them and they don't need the branch, cast, or type assertion.

Maybe neither here nor there, but just a thought.

@skondrashov
Copy link
Author

skondrashov commented Mar 7, 2024

I missed somehow that no-unnecessary-type-assertion is already a rule, so this would indeed apply only to actual runtime casting. I was able to set up a development environment and started trying to make an implementation, I know there's a good chance that this will be closed without community support but I'd like the rule anyway so I'm going to give it a shot.

@kirkwaiblinger
Copy link
Member

I'm +1 on this. The typescript-eslint codebase itself has unnecessary !! that could be linted out, see

function isLikelyToContainGlobalFlag(
node: TSESTree.CallExpressionArgument,
): boolean {
if (
node.type === AST_NODE_TYPES.CallExpression ||
node.type === AST_NODE_TYPES.NewExpression
) {
const flags = node.arguments.at(1);
return !!(
flags?.type === AST_NODE_TYPES.Literal &&
typeof flags.value === 'string' &&
flags.value.includes('g')
);
}
return node.type === AST_NODE_TYPES.Identifier;

@Josh-Cena
Copy link
Member

Josh-Cena commented Jun 2, 2024

Duplicate of #3204

Closed the other one

@Josh-Cena Josh-Cena marked this as a duplicate of #3204 Jun 2, 2024
@Josh-Cena Josh-Cena closed this as not planned Won't fix, can't repro, duplicate, stale Jun 2, 2024
@Josh-Cena Josh-Cena reopened this Jun 2, 2024
@Josh-Cena
Copy link
Member

Josh-Cena commented Jun 2, 2024

For the record I'm strongly in favor of reporting all unnecessary type coercions. Not just String() but +, !!, BigInt etc. too

@Josh-Cena Josh-Cena changed the title Rule proposal: no-unnecessary-cast for strings (maybe numbers too) Rule proposal: no-unnecessary-coercion Jun 2, 2024
@skondrashov
Copy link
Author

skondrashov commented Jun 15, 2024

I implemented this, with tests, fix functions, and docs which pass yarn test, over at https://github.com/skondrashov/typescript-eslint

It gives autofixable errors for:

// setup
let str = "string";
const num = 1;
const bool = true;
const big = BigInt(1);

// begin invalid cases

String("hello");
"hello".toString();
"" + "hello";
"hello" + "";

String(1 + "");
(1 + "").toString();
"" + (1 + "");
1 + "" + "";

String(str);
str.toString();
"" + str;
str + "";
str += "";

Number(+"1");
+(+"1");
~~+"1";

Number(num);
+num;
~~num;

Boolean(bool);
!!bool;

BigInt(big);

and allows:

// setup
let str = Math.random() > 0.5 ? "string" : 1;
const num = Math.random() > 0.5 ? 1 : "1";
const bool = Math.random() > 0.5 ? true : 1;
const big = Math.random() > 0.5 ? BigInt(1) : 1;

// begin valid cases

String(str);
str.toString();
`${str}`;
"" + str;
str + "";
str += "";

new String("asdf");

Number(num);
+num;
~~num;
~1;

Boolean(bool);
!!bool;
!false;

BigInt(big);

It does not do anything with Symbol() because everything weird I tried to do with symbols was already a typescript error. Similarly, a sizable set of conversions to number via coercion are also forbidden by typescript ("The left-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type."), including:

"123" * 1;
"123" / 1;
"123" - 0;
"123" | 0;

It also doesn't cover `${"123"}`, because that overlaps with the no-useless-template-literals rule and I don't know how to resolve that.

One thing is I don't think the word "coercion" is entirely accurate for this rule. I think "conversion" or even "type conversion idiom" might be the most precise, because .toString() for example doesn't rely on coercion, and while !!value and !value both coerce the type of value to a boolean, it's the property of the idiom !! that it can be used for type conversion that puts it under the umbrella of this rule. So I think no-unnecessary-type-conversion is a better name for this, given the things that I'm currently having it check for.

I haven't opened a PR since this isn't currently accepting PRs, but I'm close to being ready to.

Catches these errors within @typescript-eslint, which appear to be valid except for no-duplicate-enum-values where it's not clear to me what the author intended:

eslint-plugin\src\rules\adjacent-overload-signatures.ts
  79:21  error  Using !! on a boolean does not change the type or value of the boolean  @typescript-eslint/no-unnecessary-coercion
  97:21  error  Using !! on a boolean does not change the type or value of the boolean  @typescript-eslint/no-unnecessary-coercion
eslint-plugin\src\rules\explicit-function-return-type.ts
  170:27  error  Using !! on a boolean does not change the type or value of the boolean  @typescript-eslint/no-unnecessary-coercion
  177:9   error  Using !! on a boolean does not change the type or value of the boolean  @typescript-eslint/no-unnecessary-coercion
eslint-plugin\src\rules\member-ordering.ts
  486:14  error  Using !! on a boolean does not change the type or value of the boolean  @typescript-eslint/no-unnecessary-coercion
eslint-plugin\src\rules\no-duplicate-enum-values.ts
  50:21  error  Passing a string to String() does not change the type or value of the string  @typescript-eslint/no-unnecessary-coercion
  52:21  error  Passing a number to Number() does not change the type or value of the number  @typescript-eslint/no-unnecessary-coercion
eslint-plugin\src\rules\no-empty-interface.ts
  83:44  error  Using !! on a boolean does not change the type or value of the boolean  @typescript-eslint/no-unnecessary-coercion
eslint-plugin\src\rules\no-redundant-type-constituents.ts
  172:10  error  Using !! on a boolean does not change the type or value of the boolean  @typescript-eslint/no-unnecessary-coercion
eslint-plugin\src\rules\no-shadow.ts
  90:47  error  Using !! on a boolean does not change the type or value of the boolean  @typescript-eslint/no-unnecessary-coercion
eslint-plugin\src\rules\no-unsafe-assignment.ts
  202:17  error  Passing a string to String() does not change the type or value of the string  @typescript-eslint/no-unnecessary-coercion
eslint-plugin\src\rules\prefer-return-this-type.ts
  64:14  error  Using !! on a boolean does not change the type or value of the boolean  @typescript-eslint/no-unnecessary-coercion
website\src\components\ast\HiddenItem.tsx
  45:14  error  Passing a string to String() does not change the type or value of the string  @typescript-eslint/no-unnecessary-coercion
website\src\components\config\ConfigEditor.tsx
  39:9  error  Passing a string to String() does not change the type or value of the string  @typescript-eslint/no-unnecessary-coercion

@kirkwaiblinger
Copy link
Member

@skondrashov Nice work btw 🙂. It's cool to see the number of spots within the typescript-eslint codebase that get fixed by your implementation (and IMO a good demo in favor of the rule).

@kirkwaiblinger
Copy link
Member

Noting that I think replacing explicit conversion/coercion with a satisfies expression would be a helpful suggestion to add to this rule (in addition to simply removing the conversion).

@JoshuaKGoldberg
Copy link
Member

Yeah I'm also in favor of this. Each individual case (Number(), !!, ...) doesn't bring much value, but I'm constantly running into them in assorted projects. The fact that we had violations is great evidence, +1!

This might be the first time in recent memory (or ever?) an evaluating community engagement issue ended up getting a lot of 👍s and accepted.

@JoshuaKGoldberg JoshuaKGoldberg added accepting prs Go ahead, send a pull request that resolves this issue and removed evaluating community engagement we're looking for community engagement on this issue to show that this problem is widely important labels Oct 14, 2024
@kirkwaiblinger
Copy link
Member

@skondrashov If you'd like, feel free to send a PR with your implementation 🙂

@skondrashov
Copy link
Author

#10182

thanks for the warm feedback, appreciate it

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
accepting prs Go ahead, send a pull request that resolves this issue enhancement: new plugin rule New rule request for eslint-plugin package: eslint-plugin Issues related to @typescript-eslint/eslint-plugin
Projects
None yet
Development

Successfully merging a pull request may close this issue.

5 participants