Skip to content

feat(eslint-plugin): [require-types-exports] add new rule #8443

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

StyleShit
Copy link
Contributor

@StyleShit StyleShit commented Feb 12, 2024

Closes #7670


Dear Reviewer,
Good luck on your journey!

StyleShit.

@typescript-eslint
Copy link
Contributor

Thanks for the PR, @StyleShit!

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

netlify bot commented Feb 12, 2024

Deploy Preview for typescript-eslint ready!

Name Link
🔨 Latest commit 2fd30aa
🔍 Latest deploy log https://app.netlify.com/sites/typescript-eslint/deploys/668bc85478b8da00082e5a6c
😎 Deploy Preview https://deploy-preview-8443--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 5 from production)
Accessibility: 100 (no change from production)
Best Practices: 92 (no change from production)
SEO: 90 (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.

@StyleShit StyleShit marked this pull request as ready for review February 13, 2024 19:46
@StyleShit
Copy link
Contributor Author

No idea why the lint/tests are failing, seems to be fine... Am I missing something?

Comment on lines 378 to 379
'ImportDeclaration[importKind="type"] ImportSpecifier, ImportSpecifier[importKind="type"]':
collectImportedTypes,
Copy link
Contributor Author

@StyleShit StyleShit Feb 15, 2024

Choose a reason for hiding this comment

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

Currently, it doesn't support this:

import { Type } from './types';

export function (arg: Type) { /* ... */ }

but only things like this:

import type { Type1 } from './types';
import { type Type2 } from './types';
import { type Type3 as Type4 } from './types';

because IDK if I can know for sure whether the imported name is a type or not
any idea if this could be done?

Copy link
Member

Choose a reason for hiding this comment

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

The type checker can determine whether something is in type space and/or value space.

But for that case of export function (arg: Type) { /* ... */ }, do we need to know whether it's type and/or value? I'd say that should be a complaint in the rule no matter what, no?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

Copy link
Contributor Author

@StyleShit StyleShit Apr 24, 2024

Choose a reason for hiding this comment

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

OK,
After thinking about it for a little bit...

What should we do in cases where someone imports a type from another module and uses it in their exported function?

// my-package/src/types.ts
export type A = number;
// my-package/src/function.ts
import type { A } from './types';

export function f(): A {
	return 1;
}
// my-package/src/index.ts
export { f } from './function';

Then, in user-land:

// my-code.ts
import { f } from 'my-package';

let value; // What type is it?
value = f();

I mean... on the one hand, we can't know whether the type is exported somehow to the user.
On the other hand, (I think?) we don't want to force everyone to (re)export the types they imported 😓

Copy link
Member

Choose a reason for hiding this comment

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

On a pure scope analysis level, we do know this. The A identifier in function.ts is part of what's exported with f().

I think it'd be very preferable to stick with the pure scope analysis strategy. Dipping into types land is much harder to work with, since type aliases sometimes become new things and sometimes are just references to existing things.

Does that answer what you're looking for?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The rule would need to check: for every identifier in the return type, is that also exported in the current file (module)?

My current logic is:
"Does this type exist in the outside world (whether it's imported/exported in the current file)?"
- If so: Great!
- Otherwise: Report

I think we do, and that that's the goal of this rule!

I don't fully agree
I mean... yeah, that's the goal of the rule, but the type might already be exported through the index file, for example, and we'll force developers to export it multiple times in their code.

Let's take my example above and modify it a little bit:

// my-package/src/types.ts
export type A = number;
// my-package/src/f1.ts
import type { A } from './types';

export function f1(): A {
	return 1;
}
// my-package/src/f2.ts
import type { A } from './types';

export function f2(): A {
	return 2;
}
// my-package/src/index.ts
export { f1 } from './f1';
export { f2 } from './f2';

export * from './types';

Then, in user-land:

// my-code.ts
import { f1 } from 'my-package';
import type { A } from 'my-package';

let value: A; // I know what the type is! YAY!
value = f1();

This code is totally fine. But with the changes you suggest, it'll have 2 more redundant exports (and it'll increase linearly based on the type usage inside other modules).
Yeah, it probably won't hurt anybody, but it will make the DX awful, don't 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.

If we don't enforce that a export function fn(): A; is in a file that also exports A, what is the point of this rule?

Copy link
Contributor Author

@StyleShit StyleShit Apr 24, 2024

Choose a reason for hiding this comment

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

you could say the same about not exporting it from the index, no?
I mean, you'll always have holes in the rule unless you read the whole codebase, starting from the bundler entrypoint(s)

It's a small code change to enforce that imported types are also exported, I'm just trying to understand where the line lies in this case

Copy link
Member

Choose a reason for hiding this comment

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

For lint rules, the line generally lies with the single file being linted. Because lint rules operate on a single file at a time, that naturally falls out of the architecture.

It's pretty common that lint rules will only be able to catch issues within the small scope of a single rule at a time. For example, no-unused-vars won't detect things that are exported from a file but never imported. Hence Knip.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

do you wanna loop in the triage team so we can agree on something together?

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.

Lovely start! I've wanted this for ages, so very excited to see you working on it 😄.

Leaving a preliminary review as I think it'll need to be expanded to at least variables. Really, anything that can refer to a type: classes, interfaces / type aliases, namespaces, ...

Good luck on your journey. 😜


const returnStatements = new Set<TSESTree.Node>();

simpleTraverse(functionNode, {
Copy link
Member

Choose a reason for hiding this comment

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

[Refactor] I don't think simpleTraverse is what we should use here. It's tailored to the way it's currently used in packages/typescript-estree:

  • There's no way to break, but it looks like this usage would want to halt traversal after finding a ReturnStatement
  • It allows configurable selectors with 'enter', but this code just uses ReturnStatement

I think it'd be better to avoid simpleTraverse here, and instead do some or all of:

  • Look into the existing forEachReturnStatement, maybe it's exactly what you need?
  • Failing that, write a dedicated simpler traverser for just this rule that halts on ReturnStatements, omitting anything around parent pointers or 'enter'
  • Reuse some of the design and logic from one of the rules that does similar things: explicit-module-boundary-types, explicit-function-return-type
    • This one I'm low confidence in, but figured I'd mention...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Created a custom traverser, inspired by the original forEachReturnStatement

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.

Progress! 🚀

I think this is making great headway through the slog of edge cases and tests that such a nuanced rule would need. Very nice to see.

Requesting changes on:

  • Edge cases: left a few comments suggesting them, marked as [Bug] or [Testing]
  • Structure: streamlining traversals a bit to not do extra work (avoiding simpleTraverse)

I also started a thread for any open questions I saw. If I'm missing one of yours, sorry, please yell at me. 🙂

'ImportDeclaration ImportNamespaceSpecifier': collectImportedTypes,
'ImportDeclaration ImportDefaultSpecifier': collectImportedTypes,

'Program > ExportNamedDeclaration > TSTypeAliasDeclaration':
Copy link
Member

Choose a reason for hiding this comment

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

[Testing] Nothing fails with the Program > removed. Either there's a missing test or this is unnecessary.

Suggested change
'Program > ExportNamedDeclaration > TSTypeAliasDeclaration':
'ExportNamedDeclaration > TSTypeAliasDeclaration':

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added missing tests

Copy link
Member

Choose a reason for hiding this comment

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

[Testing] I'd think keyof and/or typeof types should be flagged too:

const fruits = { apple: true };

export function getFruit<Key extends keyof typeof fruits>(
  key: Key,
): (typeof fruits)[Key] {
  return fruits[key];
}

Thoughts?

Copy link
Contributor Author

@StyleShit StyleShit Jul 7, 2024

Choose a reason for hiding this comment

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

That's interesting. So we'll need to flag both types and values?

If so - I'll probably need to rework the reporting logic to also support values, and we should probably rename the rule (?)

EDIT:
Not sure that the developers will always want to export the value, since it's being exported by reference, which means external consumers will be able to change the internals of the code.
Maybe that's the case most of the times?

Copy link
Member

Choose a reason for hiding this comment

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

In this case, it's really the keyof typeof fruits that needs to be exported. So I'd assume the squiggly would be on there:

export function getFruit<Key extends keyof typeof fruits>(
//                                   ~~~~~~~~~~~~~~~~~~~

This would certainly make it difficult to write an auto-fixer 😅 glad that that's out of scope for this PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think we need to report only the typeof part, because that's what really blocks the developers from using it, no?

Do you think we should have a different message for this case? Maybe something like "typeof needs to be exported. please extract it to a type alias"?

Copy link
Member

Choose a reason for hiding this comment

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

only the typeof part, because that's what really blocks the developers from using it, no?

Well, it's certainly better...

export type Fruits = typeof fruits;

export function getFruit<Key extends keyof Fruits>(
//                                   ~~~~~~~~~~~~

...but it strikes me as a kind of arbitrary exception to allow a keyof ... to be inline. What would make this so special?

different message for this case

+1, yes!

Copy link
Contributor Author

@StyleShit StyleShit Jul 8, 2024

Choose a reason for hiding this comment

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

What would make this so special?

IDK, what's the difference between these two?

export declare function(key: keyof Items);
export declare function({ items }: { items: Items });

In both cases you construct a type inline based on a type reference. So why wouldn't we force every inline type to be extracted to an alias?

What makes keyof special compared to a regular TSTypeLiteral in this case?


EDIT:
And sorry for all of the notifications you get from my "Done" comments 😅
it helps me keep track of my progress, because I come back to this PR multiple times after switching context

Copy link
Member

Choose a reason for hiding this comment

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

Taking a step back: the core idea of the rule is that you should be able to directly import anything that's visible as a type from a module. You shouldn't have to keyof something, something['some-key'], ReturnType<typeof something>, or use any other indirection in the type system.

Looking at those two examples, here's how the rule should squiggly them:

export declare function(key: keyof Items);
//                           ~~~~~~~~~~~
export declare function({ items }: { items: Items });
//                                 ~~~~~~~~~~~~~~~~

So why wouldn't we force every inline type to be extracted to an alias?

We should.

Copy link
Contributor Author

@StyleShit StyleShit Jul 8, 2024

Choose a reason for hiding this comment

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

umm... great
now I'll need to redo a lot of my work 😂

(I didn't understand this until now, which is stupid, because it's literally in the issue)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

anyway, I think it's gonna simplify a lot of my code

@JoshuaKGoldberg JoshuaKGoldberg added the awaiting response Issues waiting for a reply from the OP or another party label Jul 6, 2024
@StyleShit
Copy link
Contributor Author

@JoshuaKGoldberg

OK, so before I delete most of the code and start basically from scratch, let's go over some cases to make sure we're on the same page?

  1. I'm assuming this is fine (easy):
type A = number;

export declare function func(a: A): void;
//                              ~
  1. Also this one:
type A = number;

export declare function func<T extends A>(a: T): void;
//                                     ~
  1. And as we said before, this one should also report:
type A = number;

export declare function func({ a }: { a: A }): void;
//                                  ~~~~~~~~
  1. But what about this one?
type A = number;

export declare function func([ a ]: [ A ]): void;
//                                  ~~~~~
  1. Or this one?
type A = number;

export declare function func([ a ]: A[]): void;
//                                  ~~~
  1. You can also have something like this, should it be reported?
type A = number;

export declare function func(a: Record<string, A>): void;
//                              ~~~~~~~~~~~~~~~~~

I mean... you can still construct all of them (3-6) easily if you have A, so where do we draw the line where we say "ok, this one should be reported"?

Should we just say "everything has to be a type alias, including things like simple unions and arrays"?
(well... it's impractical and probably impossible [think about returning T | null for example], so probably no... but I'm talking hypothetically)

In addition, should we also force everyone to have explicit return types on their functions?
It'll be easier to implement & report, but again, will probably have its own issues...

Anyway, my point is - seems like there are a lot of open questions.
Should we go back to the issue and discuss this again? Should we start with a smaller scope that includes some specific cases and expand in the future?
I'm fine with any decision, I just think we need a better definition for this rule.


OK, that was a little bit longer than I expected 😅

@kirkwaiblinger kirkwaiblinger removed the awaiting response Issues waiting for a reply from the OP or another party label Jul 15, 2024
@JoshuaKGoldberg
Copy link
Member

open questions ... go back to the issue and discuss this again?

Yeah, agreed, you bring up a lot of important edge cases. I'll post there.

@JoshuaKGoldberg
Copy link
Member

👋 Just checking in @StyleShit, is this still something you have time & energy for?

@StyleShit
Copy link
Contributor Author

@JoshuaKGoldberg

Thanks for checking 😄
I have the energy, but unfortunately don't have the time right now due to being in reserve duty.
Anyway, I think I'll open a new PR anyway when I have the time to work on it, because this one is a total mess 😅

@JoshuaKGoldberg
Copy link
Member

Cool! I'd really like to get this one and #8509 in by the end of the year, they're great rules to have that I've wanted for a while. So let's timebox these two to, say, the end of the month. If you don't end up having time by then, no worries, one of us on the team can send in a PR with the same commit history & a co-author attribution.

Good luck + best wishes on reserve duty. ❤️

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
enhancement: new plugin rule New rule request for eslint-plugin
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Rule proposal: Export all types used in exports
5 participants