8000 feat(eslint-plugin): add new rule `no-extraneous-return-types` by ronami · Pull Request #10464 · typescript-eslint/typescript-eslint · GitHub
[go: up one dir, main page]

Skip to content

feat(eslint-plugin): add new rule no-extraneous-return-types #10464

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

Closed

Conversation

ronami
Copy link
Member
@ronami ronami commented Dec 6, 2024

PR Checklist

Overview

This PR attempts to tackle #6442 and add a new rule to report on unused return type annotations:

function test (): string | null {
  return 'string'
}

Some of my thoughts:

  • The implementation takes the approach described here, and the rule warns on inline union types but not on referenced ones. This is important since referenced union types may be harder to fix and maintain.

    Consider the following example:

    interface A {};
    interface B {};
    interface C {};
    interface D {};
    
    // used throughout the project
    export type Base = A | B | C | D;
    
    function getNode(): Base {
     // ...
    }

    The function may only be returning a couple of the possible types in the union type and not all of them. Returning a base type is convenient, and having the function to declare each type it may return is tedious, especially in cases with large union types (e.g. TSESTree.Node).

    Another example is using a type exposed by a third-party library that is usually used contextually, but if the function is extracted, it may have an explicit return type annotation (vite plugins come to mind):

    export type VitePlugin = { kind: 'one' } | { kind: 'two' };
    
    function createVitePlugin(): VitePlugin {
      return {
        kind: 'one',
      };
    }

    In my opinion, asking functions to declare exactly the type they're returning can be tedious, especially if some of these types come from a third party and aren't exposed (an example that comes to mind is React.ReactNode).

  • The implementation includes special treatment for opening promises (also async functions), arrays, and tuples. Additional ones can be added (e.g. Set, Map, and potentially object-literal fields):

    // Unused or redundant union type 'null' in return type.
    function test(): Promise<string | null> {
      return Promise.resolve('hello');
    }
  • The rule doesn't include a suggestion or an auto-fix. I think an auto-fix is correct here, but I'll be happy to hear some opinions.

  • Sometimes, not all code paths return a value on a function. Because of this, the rule will not report void or undefined being potentially unnecessary on the return type annotation:

    function test(a: boolean): number | undefined {
      if (a) {
        return 1;
      }
    }
  • Running this on typescript-eslint's code base shows what seems like 7 valid reports (some show wrong types, some highlight a problematic implementation): 1, 2, 3, 4, 5, 6, 7.

  • I think it may be helpful to have the option to ignore module boundaries (e.g. exported functions or public methods of exported classes/objects). It's convenient to sometimes have unused return types, so calling code will need to handle potential future return types.

  • When the rule reports a failure, it stringifies the name of the redundant type: Unused or redundant union type '{{type}}' in return type.. While this works well for flat unions (e.g. string | number | boolean), I'm not sure how clear this is when the rule fails on a nested box like a promise or an array: Array<number | string | boolean>. Wdyt? Should the message be different for box (and potentially nested box) values?

🚀

@typescript-eslint
Copy link
Contributor

Thanks for the PR, @ronami!

typescript-eslint is a 100% community driven project, and we are incredibly grateful that you are contributing to that community.

The core maintainers work on this in their personal time, so please understand that it may not be possible for them to review your work immediately.

Thanks again!


🙏 Please, if you or your company is finding typescript-eslint valuable, help us sustain the project by sponsoring it transparently on https://opencollective.com/typescript-eslint.

Copy link
nx-cloud bot commented Dec 6, 2024

View your CI Pipeline Execution ↗ for commit 7440e51.

Command Status Duration Result
nx test eslint-plugin --coverage=false ✅ Succeeded 7m 20s View ↗
nx test eslint-plugin ✅ Succeeded 5m 58s View ↗
nx test type-utils ✅ Succeeded <1s View ↗
nx run types:build ✅ Succeeded <1s View ↗
nx run-many --target=build --exclude website --... ✅ Succeeded 24s View ↗
nx test visitor-keys ✅ Succeeded <1s View ↗
nx test utils ✅ Succeeded <1s View ↗
nx run-many --target=clean ✅ Succeeded 12s View ↗
Additional runs (24) ✅ Succeeded ... View ↗

☁️ Nx Cloud last updated this comment at 2024-12-27 09:29:37 UTC

Copy link
netlify bot commented Dec 6, 2024

Deploy Preview for typescript-eslint ready!

Name Link
🔨 Latest commit 7440e51
🔍 Latest deploy log https://app.netlify.com/sites/typescript-eslint/deploys/676e6fe0dd9f3b000880fafa
😎 Deploy Preview https://deploy-preview-10464--typescript-eslint.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 99 (🟢 up 26 from production)
Accessibility: 100 (no change from production)
Best Practices: 92 (no change from production)
SEO: 98 (no change from production)
PWA: 80 (no change from production)
View the detailed breakdown and full score reports

To edit notification comments on pull requests, go to your Netlify site configuration.

Copy link
codecov bot commented Dec 6, 2024

Codecov Report

Attention: Patch coverage is 99.34211% with 1 line in your changes missing coverage. Please review.

Project coverage is 86.98%. Comparing base (dc1e59c) to head (7440e51).
Report is 302 commits behind head on main.

Files with missing lines Patch % Lines
packages/eslint-plugin/src/util/astUtils.ts 83.33% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #10464      +/-   ##
==========================================
+ Coverage   86.76%   86.98%   +0.21%     
==========================================
  Files         444      446       +2     
  Lines       15313    15607     +294     
  Branches     4459     4547      +88     
==========================================
+ Hits        13287    13576     +289     
- Misses       1672     1675       +3     
- Partials      354      356       +2     
Flag Coverage Δ
unittest 86.98% <99.34%> (+0.21%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...int-plugin/src/rules/no-extraneous-return-types.ts 100.00% <100.00%> (ø)
packages/eslint-plugin/src/util/astUtils.ts 87.50% <83.33%> (-1.39%) ⬇️

... and 25 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@ronami ronami marked this pull request as ready for review December 6, 2024 14:48
@JoshuaKGoldberg JoshuaKGoldberg requested review from kirkwaiblinger and removed request for kirkwaiblinger December 14, 2024 15:53
Copy link
Member
@JoshuaKGoldberg JoshuaKGoldberg left a comment

Choose a reason for hiding this comment

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

This is an awesome start! 👏

I generally agree with most of the decisions you made. I commented a few feature additions I think should land with the rule inline. Looking forward to the discussion, I think this one is going to be very nuanced.

docs: {
description: 'Disallow extraneous union types in return type annotations',
requiresTypeChecking: true,
},
Copy link
Member

Choose a reason for hiding this comment

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

[Feature] Let's put this in strict? Your note that it found 7 valid reports is encouraging. Dogfooding it on our own code will be good IMO.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, I'd even consider having such a rule in recommended. Won't this be a breaking change though (and should be done in a new major)?

8000
| TSESTree.FunctionDeclaration
| TSESTree.FunctionExpression,
): void {
if (!node.returnType || node.generator) {
Copy link
Member

Choose a reason for hiding this comment

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

|| node.generator

[Bug] Generators are indeed wacky at times, but not always. As a user I'd think it a bug that the rule doesn't flag these:

function* test(): Generator<number | string> {
  return 1;
}
function* test(): Generator<never, number | string, unknown> {
  return 1;
}

Copy link
Member Author
@ronami ronami Dec 27, 2024

Choose a reason for hiding this comment

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

I've added support Generators (and also AsyncGenerators 🙈). There are some different edge cases with them.

One issue I'll be happy to hear opinions on is a generator that has no yield statements but does have a generator yield return type:

// the following:
function* foo(): Generator<string> {}

// is auto-fixed into:
function* foo(): Generator {}

Also, ones that have no yield statements but does have an explicit generator return type:

// the following:
function* foo(): Generator<string, number> {
  return 1;
}

// is auto-fixed into:
function* foo(): Generator<unknown, number> {
  return 1;
}

Copy link
Member

Choose a reason for hiding this comment

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

The rule doesn't include a suggestion or an auto-fix. I think an auto-fix is correct here, but I'll be happy to hear some opinions.

[Feature] I'd start with an auto-fix. Thinking out loud:

  • This is purely in the type system so we're not going to cause runtime error
  • Any actual necessary type shouldn't be reported on, so an invalid fix would be a bug in the rule already

Copy link
Member Author
@ronami ronami Dec 27, 2024

Choose a reason for hiding this comment

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

I've added an auto-fix 👍

One place I'm not entirely sure how to handle though, is the following:

function test1(): string {
  return 1;
}

function test2(): boolean | string {
  return 1;
}

In both of those cases, the return type is, technically, unused. There's a type error, but it's unused. I chose to report these return types as unused, but not apply an auto-fix. Wdyt about this? Should these cases be reported? Potentially with a different error message?

return;
}

const actualReturnTypes = getPotentialReturnTypes(node);
Copy link
Member

Choose a reason for hiding this comment

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

[Style] This confused me at first 😄. Is there a reason to name one "actual" and the other "potential"?

If not, request: use the same term for both?

After reading through it, "actual" matches how I'm understanding it. So +1 from me on that one.

node: TSESTree.TypeNode,
returnTypes: ts.Type[],
allowUnusedVoidOrUndefinedValues = false,
): void {
Copy link
Member

Choose a reason for hiding this comment

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

[Style] This is totally optional and if you don't like it I won't be upset at all 😄, but: I found this function to be a lot to read through. It's essentially a set of individual cases to check: tuple types, promise type argument, array type argument, miscellaneous. It might be clearer to split it up?

function checkReturnType(...) {
  const tupleElementTypes = getTypeArgumentIfTupleNode(node);
  if (tupleElementTypes) {
    checkTupleElementReturnTypes(...);
    return;
  }

  const promiseTypeArgument = getTypeArgumentIfPromiseNode(node);
  if (promiseTypeArgument) {
    checkPromiseTypeArgumentReturnTypes(...);
    return;
  }

  // (similar const/if/check/return for array type arguments)

  // (similar if/check/return for union types)

  // ...miscellaneous logic
}

If you don't like this, not a problem at all - just a personal preference of mine.

const services = getParserServices(context);
const checker = services.program.getTypeChecker();

function getRelevantReturnTypes(
Copy link
Member

Choose a reason for hiding this comment

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

[Style] Nit: this function both gets and checks. It took me a re-read to get that it does both. WDYT about expanding its name to mention both?

Suggested change
function getRelevantReturnTypes(
function getRelevantReturnTypesOrReport(


## When Not To Use It

This rule can be completely redundant for projects without explicit return type annotations on functions. It works best when coupled with other rules that enforce explicit return type annotations such as [`explicit-function-return-type`](https://typescript-eslint.io/rules/explicit-function-return-type) or [`explicit-module-boundary-types`](https://typescript-eslint.io/rules/explicit-module-boundary-types).
Copy link
Member

Choose a reason for hiding this comment

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

[Docs] I think this accidentally implies that rule is only useful with those functions. Which is not true, I've found myself occasionally wanting it in projects that don't use those rules or isolatedDeclarations. Even though I hate unnecessary types I still sometimes use them to make functions more clear or get better return types.

I think this should instead note the actual edge cases that make the rule less useful. Which are ... not that many that I can think of. Maybe:

  • If the functions are early stage and will likely be evolved/widened later
  • If you're explicitly trying to match some external, more-permissive interface

...along with the standard notes about considering using inline disables in those cases.

Copy link
Member Author
@ronami ronami Dec 27, 2024

Choose a reason for hiding this comment

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

Yes, I agree.

The current explanation of This rule can be completely redundant for projects... is just wrong (and may make incoming developers miss a lot of the value of such a rule). I usually omit return type annotation in projects, and I still have dozens of functions I choose to annotate.

I updated this section, let me know what you think 👍

Copy link
Member

Choose a reason for hiding this comment

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

The implementation includes special treatment for opening promises (also async functions), arrays, and tuples. Additional ones can be added (e.g. Set, Map, and potentially object-literal fields)

[Feature] How about ... any generic shape altogether? It's kind of limiting to not be able to check user-defined classes:

class Box<T> {
  constructor(public readonly value: T) {}
}

function makeBox(): Box<number | string> {
  return new Box("abc");
}

How difficult / edge-case-y would that be?

Copy link
Member Author
@ronami ronami Dec 15, 2024

Choose a reason for hiding this comment

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

That's brilliant! It was rather simple to do (though I still need to check I haven't missed any edge cases), thanks!

Copy link
Member Author
@ronami ronami Dec 20, 2024

Choose a reason for hiding this comment

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

Okay, this is significantly more complex than I initially considered. Summarizing my thoughts about this.


While the example above can be tackled easily enough, for some boxed values, it would be difficult to tell which return statements match:

// matching an object literal against a class type
class BoxClass<T> {
  constructor(public readonly value: T) {}
}

function test1(): BoxClass<number | string> {
  return { value: 'abc' };
}

// matching against a non-class boxed type
interface BoxType<T> {
  value: T;
}

function test2(): BoxType<number | string> {
  return { value: 'abc' };
}

// type that creates nested boxes
type NestedBox<T> = BoxClass<BoxClass<T>>;

function test3(): NestedBox<number | string> {
  return new BoxClass(new BoxClass(10));
}

// types that make computations rather than create boxed values
function test4(): ReturnType<(() => number) | (() => string)> {
  return 10;
}

I think that as long as the rule doesn't warn unnecessarily, it's OK for it to not be able to catch all of these cases. Some of these cases are missed by similar comparisons (playground link).

@JoshuaKGoldberg JoshuaKGoldberg added the awaiting response Issues waiting for a reply from the OP or another party label Dec 14, 2024
@ronami
Copy link
Member Author
ronami commented Dec 27, 2024

One issue I noticed, which I'm not sure if the rule should special case, is the following (I noticed this on tseslint's code-base):

// noUncheckedIndexedAccess turned off

function foo(a: string[]): string | null {
  return a[0] ?? null;
}

The type of this expression is string rather than string | null, and it looks like this has been special-cased by no-unnecessary-condition (in #1534). The null return type then shows up as unused, even though it is possible and even allowed by no-unnecessary-condition.

Should it be special-cased by this rule too?

@github-actions github-actions bot removed the awaiting response Issues waiting for a reply from the OP or another party label Dec 27, 2024
@kirkwaiblinger
Copy link
Member

One issue I noticed, which I'm not sure if the rule should special case, is the following (I noticed this on tseslint's code-base):

// noUncheckedIndexedAccess turned off

function foo(a: string[]): string | null {
  return a[0] ?? null;
}

The type of this expression is string rather than string | null, and it looks like this has been special-cased by no-unnecessary-condition (in #1534). The null return type then shows up as unused, even though it is possible and even allowed by no-unnecessary-condition.

I feel like every 3 months or so I have an eye-opening revelation about how bizarrely TS can break my assumptions, and this is one such case. Coincidentally I was just looking at a very similar situation in microsoft/TypeScript#60813

Should it be special-cased by this rule too?

I'm begrudgingly in favor of trying to special case this... One could imagine recursing on ternary, nullish coalescing, possibly even logical &&/|| operands to look for possibly returned types rather than just the overall expression 🤷‍♂️

@JoshuaKGoldberg
Copy link
Member

🤔 I feel like the allowing basic array indexing false positives isn't as good as it used to be. #1534 (January-March 2020) predates TypeScript 4.1 (November 2020). Now that we have noUncheckedIndexAccesses, indexed types have the option to be better handled by the compiler...

@ehoogeveen-medweb
Copy link

🤔 I feel like the allowing basic array indexing false positives isn't as good as it used to be. #1534 (January-March 2020) predates TypeScript 4.1 (November 2020). Now that we have noUncheckedIndexAccesses, indexed types have the option to be better handled by the compiler...

Anecdotal and it's been a while since I tried it, but I found noUncheckedIndexedAccess pretty awkward to use. For arrays in particular, the problem is that TS has no "array length inference" - either you have tuples with an immutable length, or you have arrays with no guarantees about the length where you have to check every time. I played around with workarounds like intersecting with tuples like [T, ...T], but that's very unsafe when combined with functions that modify arrays in place (like .pop() or .push()). I had similar problems with objects, although I think some of those may be fixed now.

@JoshuaKGoldberg
Copy link
Member

Ha, yeah, same. I've tried the flag repeatedly and never ended up liking it.

@kirkwaiblinger
Copy link
Member

The corollary though is that then we "should" flag this code too (depending on tsconfig settings)

function foo(): string | null {
    if (true) {
        return ""
    } else {
        return null;
    }
}

because this is valid TS

function foo(): string  {
    if (true) {
        return ""
    } else {
        return null;
    }
}

(playground - sometimes this doesn't work right... make sure target is set to something recent)

@kirkwaiblinger
Copy link
Member

I think it makes more sense for us to check what we can syntactically determine may be returned, even if TS doesn't actually use that strategy (yet?) in its actual return type validation.

@kirkwaiblinger kirkwaiblinger added the awaiting response Issues waiting for a reply from the OP or another party label Jan 24, 2025
@ronami
Copy link
Member Author
ronami commented Apr 17, 2025

I'm having a hard time finding time to finish working on this, I hope I can get back to this soon, but I'm closing this PR until I do.

If anyone is reading this and wants to tackle this, please feel free to use this as much as you want ❤️

@ronami ronami closed this Apr 17, 2025
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Apr 25, 2025
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
awaiting response Issues waiting for a reply from the OP or another party
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Rule proposal: disallow unused return types
4 participants
0