Skip to content

Enhancement: no-unused-vars option for allowing type-only declare class expressions #10918

Open
@LukeAbby

Description

@LukeAbby

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

My proposal is suitable for this project

  • I believe my proposal would be useful to the broader TypeScript community (meaning it is not a niche proposal).

Link to the rule's documentation

https://eslint.org/docs/latest/rules/no-unused-vars

Description

While no-unused-var triggering on most values that are only used in types makes sense, this breaks down when doing advanced things with classes. I propose an option to allow declare class expressions to be counted as used by types.

I run into this frequently when writing declaration files, specifically because I write type-only subclasses to widen the class (the most frequent example is to create a bound that allows abstract classes and changed constructors). I recognize this is niche, a more universal example would be typing JS mixins or in general functions that return internally-scoped classes.

For example if you encounter a JS function like:

function Mixin(BaseClass) {
    return class Mixed extends BaseClass { ... }
}

It is most nicely typed as:

// Not really defined at the top level of this file but hoisted because there's no way to inline and it'd be really hideous if it did work. Module visibility means that this fake value existing doesn't matter in practice.
declare class Mixed { ... }

type AnyClass = new (arg0: never, ...args: never[]) => object;
function Mixin<BaseClass extends AnyClass>(BaseClass: BaseClass): BaseClass & Mixed;

For those who want to comply with this rule, some fake-value classes can be successfully turned into types, though it is more verbose:

declare class SomeClass {
    static staticProp: number;
    instanceProp: number;
}

Can be converted to:

interface SomeClassConstructor {
    staticProp: number;
    new (): SomeClass;
}

interface SomeClass {
    instanceProp: number;
}

This already is a mild sacrifice because it's more verbose, forces you to separate instance and static props (even if they make more intuitive sense to order them interspersed), and you lose the ability to write typeof SomeClass to mean the class, which to me feels more intuitive if you're used to regular classes.

However there's no valid transformations when:

  • The class has an accessibility modifier on a property, i.e. private, protected, readonly, and internal
  • The class has a truly private property. This may seem unimportant but they have implications towards the variance of the class so stripping them is not a true 1:1 mapping.
  • The class has a getter or setter. While in common cases you could emulate them by creating a property this breaks down when the getter/setter can't be unified (e.g. set number, get string) and it also breaks subclassing as you can't substitute a getter/setter pair for a property.
  • The class is abstract.
  • The class has an internal, private, or protected constructor. You could simply leave out the constructor in SomeClassConstructor but this gives worse diagnostics.

While I'm quite comfortable with classes and translating them to regular interfaces, I also imagine most people will find the syntax for generic classes annoying. It also becomes more awkward when the base class expression is complex like mixins, especially when generic passthrough is involved:

type AnyClass = new (...args: any[]) => object;
function Mixin<BaseClass extends AnyClass>(BaseClass: BaseClass) {
    return class extends BaseClass { ... }
}

declare class Generic<T> {
    prop: T;
}

class Mixed<T> extends Mixin(Generic)<T> { ... }

You can transform Mixed to:

interface MixedConstructor {
    new <T>(): Mixed<T>;
}

type Mixed<T> = typeof Mixin<typeof Generic<T>> & { ... };

This code is obviously ugly and it doesn't generalize well to more complex mixins, especially where the generic parameters and parameters differ significantly.

Fail

declare abstract class AnyError extends Error {
    constructor(arg0: never, ...args: never[]);
}

Pass

declare abstract class AnyError extends Error {
    constructor(arg0: never, ...args: never[]);
}

type ErrorClasses = Array<typeof AnyError>;

Additional Info

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancement: plugin rule optionNew rule option for an existing eslint-plugin ruleevaluating community engagementwe're looking for community engagement on this issue to show that this problem is widely importantpackage: eslint-pluginIssues related to @typescript-eslint/eslint-plugin

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions