Skip to content

Rule proposal: Export all types used in exports #7670

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
JoshuaKGoldberg opened this issue Sep 18, 2023 · 20 comments
Open
6 tasks done

Rule proposal: Export all types used in exports #7670

JoshuaKGoldberg opened this issue Sep 18, 2023 · 20 comments
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

@JoshuaKGoldberg
Copy link
Member

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

If a file exports a construct that relies on some other types, but doesn't export those other types, it can be difficult for other files to declare types based on the construct.

For example, suppose a ./somewhere file exports a doSomething function with a single parameter of type SomeSettings, but doesn't export SomeSettings. What if a consuming file wants to declare a local/parameter of that same SomeSettings type? There'd be no easy way to refer to SomeSettings.

import { doSomething } from "./somewhere";

export function callDoSomethingAndLog(settings: Parameters<typeof doSomething>[0]) {
  doSomething(settings);
  console.log("Done!");
}

I see this a lot in definitions for libraries. Example: saurabhnemade/react-twitter-embed#132

Fail Cases

interface SomeSettings {
  something: string;
}

export function something(settings: SomeSettings) {
  // ...
}

Pass Cases

export interface SomeSettings {
  something: string;
}

export function something(settings: SomeSettings) {
  // ...
}

Additional Info

...I could have sworn this was already filed somewhere, but I couldn't find it after a few minutes of searching. 🤷

@JoshuaKGoldberg JoshuaKGoldberg added package: eslint-plugin Issues related to @typescript-eslint/eslint-plugin triage Waiting for team members to take a look enhancement: new plugin rule New rule request for eslint-plugin labels Sep 18, 2023
@Josh-Cena
Copy link
Member

Josh-Cena commented Sep 18, 2023

What about this:

type User = { ... };
type Time = { ... };

export declare function query(str: string): { user: User; time: Time } {}

Do we report anything?

@JoshuaKGoldberg
Copy link
Member Author

I'd think so, yes. Otherwise it'd be inconvenient to provide a type annotation on this queryResult:

import { query } from "...";

let queryResult /* : ...? */ ;

// ...

queryResult = query("...");

@Josh-Cena
Copy link
Member

Josh-Cena commented Sep 18, 2023

In this case even if we export those types the user still types { user: User; time: Time } which appears worse than ReturnType<typeof query> (because signature changes, etc. etc.) So is the rule really asking for all exported variables to either (a) have a single and exported type or (b) have each parameter and return type to be a single and exported type?

Also there's the following pattern to consider:

export namespace Foo {
  export type T = ...;
  export type U = ...;
}

export function foo(t: Foo.T, u: Foo.T): void;

@JoshuaKGoldberg
Copy link
Member Author

They can still use ReturnType<typeof query>, though, right? I suppose this would be more of an improvement if they want to do:

function withUserObject(user: User) {
  // ...
}

withUserObject(queryResult.user);

As long as the name of the type/etc. is accessible publicly, that'd satisfy my understanding of the rule. So that namespace Foo example would be ✔️ because Foo.T and Foo.U are accessible to external files.

@bradzacher
Copy link
Member

Every type reachable from the module's signature must be exported.
Otherwise you end up with the most egregious instance of not exporting types that is known to mankind

There's no reason someone couldn't use typeof to type things with or without this rule.
This just makes it possible for someone to freely compose a library's types without having to resort to that BS if they don't want to.

Otherwise you have to do BS like this
import * as webpack from 'webpack';

declare module 'webpack' {
  import { Compilation, LoaderDefinitionFunction, Template } from 'webpack';

  export type SourceMap = Exclude<NonNullable<Parameters<LoaderDefinitionFunction>[1]>, string>;
  export type AdditionalData = NonNullable<Parameters<LoaderDefinitionFunction>[2]>;

  export type LoaderContextResolveFn = ThisParameterType<LoaderDefinitionFunction>['resolve'];
  export type LoaderContextGetResolveFn = ThisParameterType<LoaderDefinitionFunction>['getResolve'];
  export type LoaderContextLoadModuleFn = ThisParameterType<LoaderDefinitionFunction>['loadModule'];
  export type LoaderContextEmitErrorFn = ThisParameterType<LoaderDefinitionFunction>['emitError'];
  export type LoaderContextEmitWarningFn =
      ThisParameterType<LoaderDefinitionFunction>['emitWarning'];
  export type ResolveOptionsWithDependencyType =
      NonNullable<Parameters<LoaderContextGetResolveFn>[0]>;

  export type ChunkGroup = ReturnType<Compilation['addChunkInGroup']>;
  export type ChunkRenderContext = Parameters<typeof Template['renderChunkModules']>[1];

  export type DependencyTemplate =
      NonNullable<ReturnType<Compilation['dependencyTemplates']['get']>>;
  export type DependencyTemplateContext = DependencyTemplate extends {
    apply(dep: any, source: any, context: infer X): any,
  } ? X
      : never;

  export type RenderManifestOptions = Parameters<Compilation['getRenderManifest']>[0];
}

Yes - this is actual code we have at canva so that we can just write plain old webpack code with types. Not even delving into internals - just using their APIs.

It's either that or you go "fuck it lets just patch the package".

@JoshuaKGoldberg
Copy link
Member Author

JoshuaKGoldberg commented Aug 14, 2024

Bringing discussion back over from #8443 (comment):

where do we draw the line where we say "ok, this one should be reported"?

Summarizing: I think the gray areas are when a type is used in an inline position. Particularly tricky when some type A is exported itself, but something that uses it is inline and so not exported. Listing each common case, where the syntax in question is example's return type:

  • Arrays, like function example(): A[];
    • Tuples, like function example(): [A];
  • Functions, like function example(): () => A;
  • Global types' type parameters, like function example(): Record<string, A>;
  • Objects, like function example(): { a: A };
  • Operators, like function example(): keyof A;
  • Unions, like function example(): A | null;

I'll call those "wrappers": e.g. in A[], the real type is A, and it's wrapped with an []. My understanding of where we're not yet settled is: for each of those wrappers, should the rule request it be exported as its own type?

Referring to what Brad said in #7670 (comment): every type reachable from the module's signature must be exported. We could start by defining "type" as a local identifier (e.g. A). For all of those cases above, as long as A is exported from the file, the type can be reconstructed.

But, they all entail varying level of difficulty to be reconstructed. For example, simple arrays (A[]) are pretty easy and would be annoying to manually extract a type for. But something with more arrays & tuples like [A[], A[], A[]] could reasonably be requested as its own type.

The cleanest starting answer I can think of would be to ignore anything that's not a local identifier or object literal. Those are the main things that motivated this issue in the first place. So, going back to the function example(): ... A list of wrappers:

  • Allow: arrays, tuples, functions, global types' parameters, operators, unions
  • Report: object literals

That's a little less strict from my suggestion in #8443 (comment). We could see how this works in userland for a while and maybe later add options to enable more strictness. IMO if we try to force people to extract types for simpler things like arrays and unions then they'll resist using the rule at all.

should we also force everyone to have explicit return types on their functions?

Oh, blurgh, this is a good question. Similar to @typescript-eslint/no-unnecessary-type-parameters - functions with implicit, inferred returns are kind of a blind spot of pure AST analysis...

// type-utilities.d.ts
export declare function identity<T>(value: T): T;
// index.ts
import { identity } from "./type-utilities";

// You'd think the rule should error on this, right?
// ...but in pure AST land without types, that's not doable.
interface Box {
  value: string;
}

// Implicit inferred return type: Box
function makeBox() {
  return identity<Box>({ value: "abc" });
}

The problem is, even with full type checking we wouldn't catch every case. Some constructs, namely literals and type aliases, often get inlined or interned in the type system.

type Value = "abc";

// Implicit inferred return type: "abc"
// The Value type is interned in the type system
function makeBox() {
  return identity<Value>("abc");
}

My vote would be to not use type-checking at all, and comment is as an explicit out-of-scope point in the rule's docs.
If folks want to get coverage on that then they can use @typescript-eslint/explicit-module-boundary-types for now.
Later on we could always make a rule with a name like require-return-type-exports that does use type checking.

Anyway, pinging @StyleShit + @typescript-eslint/triage-team - what does everyone think?

@JoshuaKGoldberg JoshuaKGoldberg added triage Waiting for team members to take a look awaiting response Issues waiting for a reply from the OP or another party labels Aug 14, 2024
@Josh-Cena
Copy link
Member

You put "tuple" in both "allow" and "report"—I assume you would allow [A] but report [A, B] or [A, A]?

What about 1 | 2 | 3 or Enum.A | Enum.B?

What about enums in general?

I assume while you allow keyof A, you would require A to be exported right?

@JoshuaKGoldberg
Copy link
Member Author

"tuple" in both "allow" and "report"

Whoops, sorry, typo from too many re-edits of that post heh. I added it back to "allow".

1 | 2 | 3 or Enum.A | Enum.B?

My proposal is to allow those...

require A to be exported right?

...so long as A / Enum are exported, yeah.

@Josh-Cena
Copy link
Member

For function x(): Enum, would you require Enum to be exported? Or function x(): Namespace.PrimitiveType, would you require Namespace to be exported?

@JoshuaKGoldberg
Copy link
Member Author

Yes to both Enum and Namespace. They're local identifiers that someone might want to use.

@StyleShit
Copy link
Contributor

Great explanation of the issue! ✨

varying level of difficulty

Well... "difficulty" is subjective, so I'm not sure how we can agree on a list of "easy" types to construct
(I mean... we can probably vote or something, IDK 😅)

Allow: arrays, tuples, functions, global types' parameters, operators, unions

Is this allowed?

function fn<T extends string>(
  arg: T[],
): [
  (subscriber: (newValue: A, oldValue: A) => void) => void,
  (
    | (Record<string, B> & {
        __brand: 'custom-brand';
      })
    | (Record<keyof C & keyof D, [T, T]> & {
        __brand: 'another-brand';
      })
  ),
];

Allow: ... operators

Just to make sure - we'll allow fn(): keyof typeof value only when value is exported, right?

Report: object literals

So things like fn(name: string): { name: string, role: string } or even fn<T extends string>(name: T): { name: T, role: string } will be reported?
It could also be considered a "simple" type 🤷

@JoshuaKGoldberg
Copy link
Member Author

Is this allowed?

Based on what my post is suggesting:

  • A, B, C, and D would need to be exported
  • The { __brand: '...' } object literals would need to be extracted and exported

Just to make sure - we'll allow fn(): keyof typeof value only when value is exported, right?

👍 that's what I'm suggesting. My suggestion is that the keyof and typeof be considered
wrappers that don't need to be extracted or exported.

So things like fn(name: string): { name: string, role: string } or even fn<T extends string>(name: T): { name: T, role: string } will be reported?

👍 that's what I'm suggesting. My suggestion is that the { name: ..., role: string } object literals be considered their own types that need to be extracted and exported.

@StyleShit
Copy link
Contributor

Based on what my post is suggesting

IDK... even after stripping out the brand, this type is very complicated IMO. How would I construct it myself as a library consumer?

object literals be considered their own types that need to be extracted and exported.

What's the difference in the "difficulty level" between { a: A } and [A, A]?

Is [A, B, C, Record<string, D | Array<string> | Map<string, number>] less difficult to construct than { a: A }?

My point is - it's very hard (impossible?) to tell when something is considered "difficult to construct" because it's subjective.

I don't think we can cover all cases by arbitrarily saying "a tuple is ok, an object is not", because again, look at the 2 types above - do you believe that constructing the tuple is easier than constructing the object?

Maybe we can consider things like:

  • "only tuples with up to 4 items, that are only primitives and/or identifiers are ok"
  • "only object literals with up to 3 properties, that are only primitives and/or identifiers are ok"

Yes, it's odd, but I think it makes more sense in this case when looking from the user's POV (it's probably gonna be a nightmare to develop, and won't catch all cases)

Maybe we can make it configurable and have some reasonable defaults? 🤷

Anyway, I think we need to find that sweet spot between "not making the library developers hate us and turn off the rule" and "helping the library consumers by making sure library developers will export their types" 😅

@bradzacher
Copy link
Member

Yeah I think that at an MVP level enforcing "all exported things use named types" is a good standard and is easy to lint for.
It allows us to get the rule out there and play around to see what the community actually wants to see. We likely will find that people want more toggles to relax the requirements (see explicit-function-return-type and friends) -- but that needn't be the MVP.

Depends how deep we want to go before we've even written anything tbh.

@JoshuaKGoldberg
Copy link
Member Author

Yeah. @StyleShit you bring up a lot of good questions on rule nuances, but for an initial version of this rule, we don't want to have so much nuance encoded in it. Arbitrary delineations like "only X up to Y items" tend to confuse end-users and feel unnecessarily arbitrary. In large part because they are.

Maybe we can make it configurable and have some reasonable defaults? 🤷

For the initial version, let's do the same strategy as no-unnecessary-type-parameters:

  1. Add an option-less initial version with a big "this is experimental and new, let us know how it goes for you notice"
    • This wouldn't be in the recommended, strict, or stylistic configs to start
  2. Wait a bit to see how it feels for us & users
  3. Then consider adding options based on that

This isn't going to be perfect in a v1, and that's ok. The best thing we can do now is start it as straightforward & understandable as possible.

@JoshuaKGoldberg JoshuaKGoldberg removed triage Waiting for team members to take a look awaiting response Issues waiting for a reply from the OP or another party labels Aug 30, 2024
@StyleShit
Copy link
Contributor

Yeah I think that at an MVP level enforcing "all exported things use named types" is a good standard and is easy to lint for.

@bradzacher, just to make sure I understand you correctly, are you proposing a different approach than @JoshuaKGoldberg?

It sounds to me more like my proposal in #8443 (comment):

Should we just say "everything has to be a type alias, including things like simple unions and arrays"?

Am I missing something?

@bradzacher
Copy link
Member

No yeah Josh's comment was fine.
Lets go for dead simple and option less and we can see how it feels and iterate.

We could spin our wheels and chat about the details for ages but really it depends on how it feels and how users react.

We may find that people are happy with the dumb rule and don't care about it being strict. But we will likely find people want relaxations. But who knows! Release and iterate let's go

@StyleShit
Copy link
Contributor

StyleShit commented Sep 24, 2024

So to make sure we're all aligned:

We wanna go with an initial version that forces you to have an exported type alias for any parameter/return-type that's an inline object literal, while still allowing any arrays, tuples, etc. that "wrap" a type alias?
(e.g. { a: A } is not allowed, while Array<A> is allowed)

In addition, what about imported types that are being used inside an exported function? Should we force re-exporting them?
See the discussion here for more context

@JoshuaKGoldberg
Copy link
Member Author

We wanna go with an initial version that forces you to have an exported type alias for any parameter/return-type that's an inline object literal, while still allowing any arrays, tuples, etc. that "wrap" a type alias?

👍 from me.

In addition, what about imported types that are being used inside an exported function? Should we force re-exporting them?
See the #8443 (comment) for more context

Let's not require re-exports. Knowing whether a type is or isn't exported across a system in some way is outside the scope of a lint rule. That'd be more in the realm of a true project-wide checker such as Knip. (not saying it should go in Knip, just that we operate at different scales)

@StyleShit
Copy link
Contributor

Sounds good to me. Will try to find some time to work on 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
4 participants