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

Proposal: Allow isolated declarations to infer results of constructor calls #60010

Open
6 tasks done
MichaelMitchell-at opened this issue Sep 19, 2024 · 10 comments
Open
6 tasks done
Labels
Experimentation Needed Someone needs to try this out to see what happens Suggestion An idea for TypeScript
Milestone

Comments

@MichaelMitchell-at
Copy link

MichaelMitchell-at commented Sep 19, 2024

πŸ” Search Terms

isolated declarations constructor generic infer

βœ… Viability Checklist

⭐ Suggestion

Add a new flag or change the behavior of --isolatedDeclarations so that in the following code which is currently disallowed by isolated declarations:

export const foo = new Foo(a);

if the type of foo is not Foo, there is a typecheck error. In return, the type of foo will be emitted as Foo.

Similarly, if somehow in

export const foo: new Foo<A>(a);

foo is not a Foo<A>, there is a typecheck error.

πŸ“ƒ Motivating Example

Currently under isolated declarations, code must redundantly declare the type of a variable which is the result of a constructor call.

export const foo: Foo = new Foo(a);

This setting would also eliminate #59768

πŸ’» Use Cases

^

@Jamesernator
Copy link

Jamesernator commented Sep 20, 2024

This probably isn't possible as the constuctor for types don't have to have any relation to the returned type. i.e. If you have new Foo() this doesn't mean the result is of type Foo (or even that a type called Foo even exists).

Like the following example couldn't work with isolatedDeclarations (if Foo was imported) as TypeScript wouldn't know that new Foo() actually returns Bar without looking into lib.ts:

// lib.ts
export type Bar = {
    prop: number;
};

export type FooConstructor = new () => Bar;

export const Foo: FooConstructor = function() { /* ... */ };

// main.ts
import { Foo } from "./lib.ts";

const f = new Foo();

@MichaelMitchell-at
Copy link
Author

MichaelMitchell-at commented Sep 20, 2024

Like the following example couldn't work with isolatedDeclarations (if Foo was imported) as TypeScript wouldn't know that new Foo() actually returns Bar without looking into lib.ts:

My proposal already covers this:

if the type of foo is not Foo, there is a typecheck error.

Since there is no type Foo, then the type of foo clearly can't be Foo πŸ˜‰

@bradzacher
Copy link
Contributor

It's worth noting that it's possible to get the type via InstanceType.

For example:

const x = new Map<string, number>();
(x satisfies InstanceType<typeof Map<string, number>>)

There may be some edge cases but this could be the mechanical transform that allows the general case to work.
TS can error in cases where it's not that simple (eg if there are complicated overloads or something).

@RyanCavanaugh
Copy link
Member

This was discussed in our initial ID meetings but a fully-correct implementation is quite fraught. The real check you have to do is pretend that a const x: Foo annotation exists, resolve that Foo (it's in type space, not expression space), and see if the Foo that resolves too is "the same" one that the constructor refers to. But even "same" is tricky; you could imagine something like

// joker.ts
export type Bar = {
  a: string;
}
export type Foo = Bar;
export const Foo = {
  new(): Foo;
}

where

import { Foo, Bar } from "./joker.js"
const f = new Foo();

where at

const f: Foo

Foo has three meanings:

  • The local import Foo
  • The target of the import joker->Foo
  • The target of the import of the alias joker->Foo->Bar

While it's true that const f: Foo = new Foo() would mean the same thing, we'd be looking at this thinking "new Foo() returns Bar, not Foo" and erroring.

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Experimentation Needed Someone needs to try this out to see what happens labels Sep 20, 2024
@RyanCavanaugh RyanCavanaugh added this to the Backlog milestone Sep 20, 2024
@jakebailey
Copy link
Member

jakebailey commented Sep 20, 2024

The design we settled on for isolatedDeclarations to start was that any error checking must not consult the checker, such that an external emitter could know when an annotation is needed/not needed without tsc.

Anything past that are "optimistic" checks where one would need to call tsc somewhere to know that the output is usable (along with defining new rules that say stuff like "new Map means : Map"). Changing this is definitely a change in strategy of the feature.

But the lack of inference on new is definitely the most annoying of the bunch.

@jakebailey
Copy link
Member

I wrote the above on my phone and the page didn't update with Ryan's comment until I sent it, oops ☹️

@MichaelMitchell-at
Copy link
Author

MichaelMitchell-at commented Sep 20, 2024

and see if the Foo that resolves too is "the same" one that the constructor refers to

Does it need to be that strict? Could we settle for checking assignability? Like treat it as semantically equivalent to new Foo() satisfies Foo (or upcast<Foo>(new Foo()) or new Foo() satisfies Foo as Foo if we're being pedantic about the behavior of satisfies)?

Anything past that are "optimistic" checks where one would need to call tsc somewhere to know that the output is usable

I think that's exactly right and I think having optimistic checks brings some needed ergonomics to using isolated declarations. I think #58800 is in the same boat (please prioritize that one over this though I'd hope both make it into 5.7).

@RyanCavanaugh
Copy link
Member

Could we settle for checking assignability?

You're talking about a different feature than isolatedDeclarations at that point. Immediately we'd get a request for strictIsolatedDeclarations which enforces that they'd be exactly identical.

@bradzacher
Copy link
Contributor

@RyanCavanaugh wouldn't your tricky case be handled by InstanceOf<typeof Foo>? playground

Would there be some pure syntax transform that could be done like that? Are there edge cases that it couldn't handle?

@Jamesernator
Copy link

Jamesernator commented Sep 21, 2024

Are there edge cases that it couldn't handle?

Unfortunately overloads don't really work with conditional types so a simple case like:

type C = {
    new(): Foo1;
    new(x: number): Foo2;
};

// The type here is always Foo2
const o: InstanceoOf<C> = new C();

If the overload-conditional types interation though could be fixed, then having a transform like would work:

type InstanceOf<Args extends ReadonlyArray<any>, C extends new(...args: Args) => any>
    = C extends new(...args: Args) => infer R ? R : never;

const o1: InstanceOf<[], C> = new C();
const o2: InstanceOf<[number], C> = new C(3);

but this would require all parameters to be suitable for inference within isolated declarations.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Experimentation Needed Someone needs to try this out to see what happens Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

6 participants
@jakebailey @RyanCavanaugh @bradzacher @Jamesernator @MichaelMitchell-at and others