Skip to content
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

InstanceType and constructor return type inference doesn’t work for built‑in subtypes of ErrorConstructor #61460

Open
ExE-Boss opened this issue Mar 21, 2025 · 4 comments
Labels
Help Wanted You can do this Possible Improvement The current behavior isn't wrong, but it's possible to see that it might be better in some cases
Milestone

Comments

@ExE-Boss
Copy link
Contributor

ExE-Boss commented Mar 21, 2025

🔎 Search Terms

  • "ErrorConstructor"
  • "InstanceType Error"
  • "InstanceType ErrorConstructor"

🕗 Version & Regression Information

  • This is the behaviour in every version I tried, and I reviewed the FAQ for entries about this.
  • I was unable to test this on prior versions because dtslint only tests back to TypeScript 5.0, and the bug workbench failed to download versions older than TypeScript 4.0.

⏯ Playground Link

https://www.typescriptlang.org/dev/bug-workbench/?#code/CYUwxgNghgTiAEkoGdnwMIFdkBcD2AtgKIwx4zwgAeOIAdsGiWRQN4BQAvu+zgJ4AHBADkiARngBeeAEk6uKHTAgAKoJAAeIgDcoEZuXR55OGJjD4YAPgDc7APT34z+AD0A-D35D4ogExSsiaKympCGgBKigDmIAYwRiZmFuS2Dk4uHl7qvkQAzIFyCkqq6pEgAGYgcCXxibjJlmmOLm6evDmiACyFwSVhmgDKfHQ4UFR1xg3mTXYtme3eIkQArL3FoWUDk0kzqXMZzlkdPqIAbOtj-WUAqhEyO9Mp1getx0sYRJchpeFLeBUMNh8MRSPt0m9PEA

💻 Code

declare class CustomError extends Error {
}

type NE1 = InstanceType<EvalErrorConstructor>;
//    ^?

type NE2 = InstanceType<RangeErrorConstructor>;
//    ^?

type NE3 = InstanceType<ReferenceErrorConstructor>;
//    ^?

type NE4 = InstanceType<SyntaxErrorConstructor>;
//    ^?

type NE5 = InstanceType<TypeErrorConstructor>;
//    ^?

type NE6 = InstanceType<URIErrorConstructor>;
//    ^?

type CE = InstanceType<typeof CustomError>;
//    ^?

Workbench Repro

🙁 Actual behavior

Type query results:

type NE1 = Error
type NE2 = Error
type NE3 = Error
type NE4 = Error
type NE5 = Error
type NE6 = Error
type CE = CustomError

🙂 Expected behavior

Type query results:

type NE1 = EvalError
type NE2 = RangeError
type NE3 = ReferenceError
type NE4 = SyntaxError
type NE5 = TypeError
type NE6 = URIError
type CE = CustomError

Additional information about the issue

Same happens with:

declare function newError<E extends Error>(ctor: new (msg?: string) => E): E;
declare class CustomError extends Error {
}

let ne1 = newError(EvalError);
//   ^?

let ne2 = newError(RangeError);
//   ^?

let ne3 = newError(ReferenceError);
//   ^?

let ne4 = newError(SyntaxError);
//   ^?

let ne5 = newError(TypeError);
//   ^?

let ne6 = newError(URIError);
//   ^?

let ce = newError(CustomError);
//   ^?

Workbench Repro

Related issues

@mkantor
Copy link
Contributor

mkantor commented Mar 21, 2025

Here's a generalized example of the relevant type system behavior.

The difference between the built-in error constructor types and your custom one is that the built-in ones inherit from ErrorConstructor on their static side.

These types are all structurally-identical though so arguably none of this behavior is "wrong" (but it does lead to non-ideal type info).

@ExE-Boss
Copy link
Contributor Author

ExE-Boss commented Mar 21, 2025

Changing the ErrorConstructor subtypes to use:

type OmitCallSignatures<T> = { [K in keyof T]: T[K] };

interface TypeErrorConstructor extends OmitCallSignatures<ErrorConstructor> {
}

would fix this specific issue, as the static side also inherits the call signatures by default.

I would expect that subinterface new/⁠call signatures would take priority over superinterface new/⁠call signatures in ReturnType<T>/InstanceType<T>function newError<T>(…), and other new? (...args: infer A) => infer R types.

@RyanCavanaugh
Copy link
Member

This is a weird interaction between overload resolution and ReturnType and I'm kind of surprised this is the first time anyone's noticed.

If you have two interfaces

interface Base {
  (): string;
}
interface Derived extends Base {
  (): "derived";
}

then we merge the overloads with the derived class first:

(): "derived";
(): string;

This is desirable because in a call position f(), the first match signature of f is the one we pick, and when f is Derived you obviously want "derived"

ReturnType does the opposite; it picks the last overload on the assumption that you have a signature list like

(s: string): number;
(s: number): string;
(s: string | number): string | number;

where the last overload is the "catch-all" that has the widest return type.

I think the right fix would be to split up ErrorConstructor so that its call/construct signatures aren't part of the interface that gets extended, e.g.

interface ErrorConstructorStatics {
  // whatever goes here
}
interface ErrorConstructor extends ErrorConstructor {
  new(message?: string): Error;
}

interface SpecialErrorConstructor extends ErrorConstructorStatics {
  new(): SpecialError;
}

this would break existing interface merges on ErrorConstructor - they'd have to merge the Statics one instead - but preserves all other useful semantics. Seems like an OK change

@RyanCavanaugh RyanCavanaugh added Help Wanted You can do this Possible Improvement The current behavior isn't wrong, but it's possible to see that it might be better in some cases labels Mar 21, 2025
@RyanCavanaugh RyanCavanaugh added this to the Backlog milestone Mar 21, 2025
@ExE-Boss
Copy link
Contributor Author

One option would be to filter out inherited overloads which are wholly subsumed by overloads declared on the subinterface, e.g.: for:

interface ErrorConstructor extends ErrorConstructor {
	new (message?: string): Error;
	new (message?: string, options?: ErrorOptions): Error;
}

interface SpecialErrorConstructor extends ErrorConstructor {
	new (message?: string): SpecialError;
	new (message?: string, options?: ErrorOptions): SpecialError;
}

Because the new signatures of SpecialErrorConstructor wholly shadows the inherited new signatures from ErrorConstructor, it’d be filtered out when doing overload resolution for ReturnType/InstanceType, e.g.:

Instead of getting a list of overloads for SpecialErrorConstructor (where the last two are unreachable):

  • new (message?: string): SpecialError;
  • new (message?: string, options?: ErrorOptions): SpecialError;
  • new (message?: string): Error;
  • new (message?: string, options?: ErrorOptions): Error;

The list of overloads checked by ReturnType/InstanceType would simply be:

  • new (message?: string): SpecialError;
  • new (message?: string, options?: ErrorOptions): SpecialError;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Help Wanted You can do this Possible Improvement The current behavior isn't wrong, but it's possible to see that it might be better in some cases
Projects
None yet
Development

No branches or pull requests

3 participants