-
-
Notifications
You must be signed in to change notification settings - Fork 2.8k
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
feat(eslint-plugin): [require-types-exports] add new rule #8443
Conversation
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. |
✅ Deploy Preview for typescript-eslint ready!
To edit notification comments on pull requests, go to your Netlify site configuration. |
packages/eslint-plugin/tests/rules/require-types-exports.test.ts
Outdated
Show resolved
Hide resolved
No idea why the lint/tests are failing, seems to be fine... Am I missing something? |
'ImportDeclaration[importKind="type"] ImportSpecifier, ImportSpecifier[importKind="type"]': | ||
collectImportedTypes, |
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.
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?
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 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?
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.
Done
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.
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 😓
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.
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?
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 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?
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 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?
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.
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
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 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.
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.
do you wanna loop in the triage team so we can agree on something together?
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.
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, { |
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.
[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 usesReturnStatement
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
ReturnStatement
s, 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...
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.
Created a custom traverser, inspired by the original forEachReturnStatement
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.
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': |
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.
[Testing] Nothing fails with the Program >
removed. Either there's a missing test or this is unnecessary.
'Program > ExportNamedDeclaration > TSTypeAliasDeclaration': | |
'ExportNamedDeclaration > TSTypeAliasDeclaration': |
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.
Added missing tests
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.
[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?
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.
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?
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.
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.
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 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"?
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.
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!
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.
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
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.
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.
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.
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)
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.
anyway, I think it's gonna simplify a lot of my code
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?
type A = number;
export declare function func(a: A): void;
// ~
type A = number;
export declare function func<T extends A>(a: T): void;
// ~
type A = number;
export declare function func({ a }: { a: A }): void;
// ~~~~~~~~
type A = number;
export declare function func([ a ]: [ A ]): void;
// ~~~~~
type A = number;
export declare function func([ a ]: A[]): void;
// ~~~
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 Should we just say "everything has to be a type alias, including things like simple unions and arrays"? In addition, should we also force everyone to have explicit return types on their functions? Anyway, my point is - seems like there are a lot of open questions. OK, that was a little bit longer than I expected 😅 |
Yeah, agreed, you bring up a lot of important edge cases. I'll post there. |
👋 Just checking in @StyleShit, is this still something you have time & energy for? |
Thanks for checking 😄 |
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. ❤️ |
Closes #7670
Dear Reviewer,
Good luck on your journey!
StyleShit.