microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.4k stars 12.41k forks source link

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

Open MichaelMitchell-at opened 1 week ago

MichaelMitchell-at commented 1 week ago

🔍 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 https://github.com/microsoft/TypeScript/issues/59768

đŸ’ģ Use Cases

^

Jamesernator commented 1 week ago

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 commented 1 week ago

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 commented 1 week ago

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 commented 1 week ago

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:

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.

jakebailey commented 1 week ago

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 commented 1 week ago

I wrote the above on my phone and the page didn't update with Ryan's comment until I sent it, oops ☚ī¸

MichaelMitchell-at commented 1 week ago

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 https://github.com/microsoft/TypeScript/issues/58800 is in the same boat (please prioritize that one over this though I'd hope both make it into 5.7).

RyanCavanaugh commented 1 week ago

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 commented 1 week ago

@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 commented 1 week ago

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.