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: Type-side use of instanceof keyword as an instance-query to allow instanceof checking #58181

Open
6 tasks done
craigphicks opened this issue Apr 13, 2024 · 6 comments
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript

Comments

@craigphicks
Copy link

craigphicks commented Apr 13, 2024

πŸ” Search Terms

type side instanceof keyword used for instanceof Querying.

related issues:
#202 #31311 #42534 #50714 #55032

βœ… Viability Checklist

⭐ Suggestion

A new type-side keyword instanceof is proposed to allow an interface to be declared as mapping to a specific class or specific constructor variable. When type-checking for assignability of said interface, the type checker would not rely only on structural duck-typing, but also rely on the tracked class/variable from which the object was constructed.

This proposal addresses runtime errors resulting from runtime use of run-side instanceof that are not caught by the type checker. It may also be useful for other reasons/purposes, as shown in "Motivating Examples and discussed in "Use Cases".

Of course absence of this feature is not a bug, as the overwhelming majority of TypeScript use cases require not using this proposed feature. However, there are a minority of use cases where this feature would be essential and/or useful, and the absence of this feature is a limitation.

Notation

The type-side syntax of instanceof requires as its operand either a variable representing a class constructor, e.g.:

class A {}
const a = new A() as instanceof A;  // (instanceof A & A)

or an instantiation statement for a generic class, e.g.:

class C<T> { constructor(t: T) {} }
const c = new C<number> as instanceof C<number>;  // // (instanceof C & C<number>)

The resulting type is called an "instance query type".
As shown above, an instance query type is displayed as a paraenthesized intersection with two componenets:

  • The first component repreents js runtime class hierarchy defined by the js x instanceof Y operator, and is called the instanceof component. It is represented in the type display by a constructor symbol, e.g., instanceof A or instance C above. This is the a new ability incorporated by this proposal.
  • The second component is the called the structural type component, which TypeScript already provides, e.g. A or C<number> above.

Although the instanceof query type is written as an paranthesized interseciton, it is not behave exactly the same as an intersection type. An instanceof query type is a unit type with some special rules.

Implementation

In order to ensure the prerequisite "This wouldn't be a breaking change in existing TypeScript/JavaScript code", the behavior of code not involving the instanceof instance-query operator will not change. Only the behavior of code that does involve the instanceof instance-query, and its resulting flow, will be affected.

Workaround for "new"

That means that the new operator will not be affected by this proposal:

class A {}
const a = new A(); // a is still of type A, not (instanceof A & A)

does not automicallly beome a: instanceof A. A cast (as above) or a convenience function:

function createA(): instanceof A { return new A();}

is required.

Workaround for "instanceof" (the runtime usage)

Likewise the instanceof operator will not be affected by this proposal:

declare const a: A;
if (a instanceof A) {
   a satisfies instanceof A; // no, this is an error
}

A convenience function can be written instead:

declare const a: A;
function extendsInstanceOfA<T>(a: T): a is (instanceof A) & T {
    return a instanceof A;
}
if (extendsInstanceOfA(a)) {
   a satisfies instanceof A; // yes, this is NOT an error
}

πŸ“ƒ Motivating Examples

Examples 1:

This code (see comment) passes type checking but throws a runtime error:

const arrayBuffer: ArrayBuffer = new Uint8Array() // ts says this is structurally ok but ...
const dataView = new DataView(arrayBuffer); // currently causes run time error

The DataView constructor requires an actual ArrayBuffer instance, but Uint8Array is not an ArrayBuffer instance.

With this proposal the user could write safeDataVew which takes an instanceof ArrayBuffer argument type:

    declare function safeDataView(buffer: instanceof ArrayBuffer): DataView;

Passed a plain ArrayBuffer type the TS compiler will error:

    let a: ArrayBuffer = new Uint8Array()
    safeDataView(a); // should error
// !!! error TS2345: Argument of type 'ArrayBuffer' is not assignable to parameter of type '(instanceof ArrayBuffer & ArrayBuffer)'.

Try to fix that error but it just moves

    let b: instanceof ArrayBuffer = new Uint8Array() // now the error is here
// !!! error TS2322: Type 'Uint8Array' is not assignable to type '(instanceof ArrayBuffer & ArrayBuffer)'.
// !!! error TS2322:   Object type Uint8Array has no constructor declared via 'instanceof' therefore is not assignable to constructor typeof ArrayBuffer
    safeDataView(b); // no error

Try again - still an error but the compiler tells us that Uint8Array is not an instanceof ArrayBuffer

    let c: instanceof ArrayBuffer = new Uint8Array() as instanceof Uint8Array; // still error, but better error explanation
//!!! error TS2322: Type '(instanceof Uint8Array & Uint8Array)' is not assignable to type '(instanceof ArrayBuffer & ArrayBuffer)'.
//!!! error TS2322:   new Uint8Array() instanceof ArrayBuffer is not true
    safeDataView(c); // no error

Examples 2:

Class instanceod heirarchy is separate from the generic type heirarchy. They are separate, but both must be satisfied for type checking to pass. This example shows an example where the instanceof comopnent passes but the generic type component fails.

        class EmptyBase {}
        class A1<T extends string|number>  extends EmptyBase{
            a: T;
            constructor(a: T) {
                super();
                this.a = a;
            }
        }
        const ANumVar = A1<number>;
        const AStrVar = A1<string>;

        const an = new A1<number>(1) as instanceof A1<number>;
        const as = new A1<string>("one") as instanceof A1<string>;

        an satisfies EmptyBase; // no error
        an satisfies instanceof A1; // no error
        an satisfies instanceof A1<number>; // no error
        an satisfies instanceof ANumVar; // no error

        as satisfies EmptyBase; // no error
        as satisfies instanceof A1; // no error
        as satisfies instanceof A1<number>; // error
//         ~~~~~~~~~
// !!! error TS1360: Type '(instanceof A1 & A1<string>)' does not satisfy the expected type '(instanceof A1 & A1<number>)'.
// !!! error TS1360:   Type 'A1<string>' is not assignable to type 'A1<number>'.
// !!! error TS1360:     Type 'string' is not assignable to type 'number'.
        as satisfies instanceof ANumVar; // error
//         ~~~~~~~~~
// !!! error TS1360: Type '(instanceof A1 & A1<string>)' does not satisfy the expected type '(instanceof A1 & A1<number>)'.
// !!! error TS1360:   Type 'A1<string>' is not assignable to type 'A1<number>'.

Examples 3-a:

Intersection of instanceof-query types with {} as structure type resolves to:

  • never if the instanceof components are not in the same linear class hierarchy
  • the most specific common ancestor if they are in the same linear class hierarchy
        class EmptyBase {}
        class A1  extends EmptyBase{
            a: number|string = "";
        }
        class A2  extends A1 {
            a: number = 0;
        }
        class A3  extends A2 {
            a: 0 | 1 = 0;
        }
        class B3  extends A2 {
            a: 1 | 2 = 2;
        }
        type AQ = instanceof A1 & instanceof A2;
        declare let a1: instanceof A1;
        declare let a2: instanceof A2;
        declare let a3: instanceof A3;
        declare let b3: instanceof B3;

        a1 satisfies AQ; // error (because (typeof A1).constructor < (typeof A2).constructor)
//         ~~~~~~~~~
// !!! error TS1360: Type '(instanceof A1 & A1)' does not satisfy the expected type '(instanceof A2 & (A1 & A2))'.
// !!! error TS1360:   'new A1() instanceof A2' would evaluate as 'false'
        a2 satisfies AQ; // no error
        a3 satisfies AQ; // no error
        b3 satisfies AQ; // no error

        type ANope = instanceof A3 & instanceof B3;
        a3 satisfies ANope; // error
//         ~~~~~~~~~
// !!! error TS1360: Type '(instanceof A3 & A3)' does not satisfy the expected type 'never'.
        b3 satisfies ANope; // error
//          ~~~~~~~~~
// !!! error TS1360: Type '(instanceof B3 & B3)' does not satisfy the expected type 'never'.

As a special consider when the intersection of an instanceof query type with a structure-only type, e.g.:

class A { a: A | undefined; constructor(a: A | undefined) { this.a = a; } }
interface Y { b:B|undefined } // no constructor!
type X =  instance A;  // (instanceof A & A)
type Z = X & Y; // (instanceof A & (A & Y)),  NOT never!

The reason that the result is not never is that , for the purpose of compiuting intersection, Y is internally "promoted" to an intanceof query type represented as

(instanceof Object) & Y

That makes sense because in js runtime, every object returned by a constructor is an instance of Object.

It follows that, for any instance query type type X = instanceof SomeClass, the intersection X & {} is always X.

Examples 4:

TypeScript considers classes with completely empty publicly declarad type structure as equivalent to any - although they might correspond to objects with rich but hidden functionality that need to be discriminated. This proposal would allow you to discriminate them.

        interface A1 {}
        interface A1Constructor {
            prototype: A1;
            new(): A1;
        }
        declare const A1: A1Constructor;

        interface A2  extends A1 {}
        interface A2Constructor {
            prototype: A2;
            new(): A2;
        }
        declare const A2: A2Constructor;

        declare let a1: instanceof A1;
        declare let a2: instanceof A2;
        const one = 1 as const;
        const sym = Symbol();

        ////////////////////////////////////////////////////////////////////
        // compare to rhs without instanceof -- none of these are errors, which might not be desirable.

        a1 satisfies A2; // not an error

        ({}) satisfies A2; // not an error

        one satisfies A2; // not an error

        1n satisfies A2; // not an error

        sym satisfies A2; // not an error


        ////////////////////////////////////////////////////////////////////
        // using instanceof queries these can now be discriminated

        a1 satisfies instanceof A2; // should be error
//         ~~~~~~~~~
//!!! error TS1360: Type '(instanceof A1 & A1)' does not satisfy the expected type '(instanceof A2 & A2)'.
//!!! error TS1360:   'new A1() instanceof A2' would evaluate as 'false'

        ({}) satisfies instanceof A2; // should be error
//           ~~~~~~~~~
//!!! error TS1360: Type '{}' does not satisfy the expected type '(instanceof A2 & A2)'.
//!!! error TS1360:   Object type {} has no constructor declared via 'instanceof' therefore is not assignable to constructor typeof A2

        one satisfies instanceof A2; // should be error
//          ~~~~~~~~~
//!!! error TS1360: Type 'number' does not satisfy the expected type '(instanceof A2 & A2)'.
//!!! error TS1360:   Object type Number has no constructor declared via 'instanceof' therefore is not assignable to constructor typeof A2

        1n satisfies instanceof A2; // should be error
//         ~~~~~~~~~
//!!! error TS1360: Type 'bigint' does not satisfy the expected type '(instanceof A2 & A2)'.
//!!! error TS1360:   Object type BigInt has no constructor declared via 'instanceof' therefore is not assignable to constructor typeof A2

        sym satisfies instanceof A2; // should be error
//          ~~~~~~~~~
//!!! error TS1360: Type 'typeof sym' does not satisfy the expected type '(instanceof A2 & A2)'.
//!!! error TS1360:   Object type Symbol has no constructor declared via 'instanceof' therefore is not assignable to constructor typeof A2

πŸ’» Use Cases

As shown in the motivating examples.

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature labels Apr 15, 2024
@craigphicks craigphicks changed the title Type-side instanceof keyword and functionality Proposal: Type-side instanceof keyword and functionality Apr 15, 2024
@saltman424
Copy link

This auxilliary proposal allows instanceof to be placed prior to class or constructor variable, acting a qualifier.

(Note: I now think this proposal might be better than the main proposal in terms of user ease and simplicity - while solving the same problems).

Agreed. I don't think restricting instanceof to only be useable with interfaces would be desirable or necessary. However, there are some interesting cases, particularly when considering using extends:

For example, using this setup:

class A { ... }
class B extends A { ... }
class C { ... }

// Basic examples of `instanceof ...` being used where you would use other types
const a1: instanceof A = new A()
const a2: typeof a1 = a1
type InstanceOfA = instanceof A
interface AlsoInstanceOfA extends InstanceOfA {
}
const a3: AlsoInstanceOfA = new A()

These accurately represent the runtime, so they probably should be allowed:

const b1: instanceof A = new B()
const b2: (instanceof A) & B = new B()
const b3: (typeof a1) & B = new B()
const b4: (instanceof A) & (instanceof B) = new B()
type AB = (instanceof A) & (instanceof B) // could be simplified under-the-hood to `instanceof B` because B extends A

And these are not possible, so they should be never:

type BC = (instanceof B) & (instanceof C)
type IndirectAC = AlsoInstanceOfA & (instanceof C)

Also, presumably mapped types reduce an instanceof type to a plain structural type. E.g.

type MappedA = { [K in keyof InstanceOfA]: InstanceOfA[K] }
const a1: MappedA = new A()
const a2: InstanceOfA = a1 // Error: MappedA matches the structure of InstanceOfA but may not be an instance of A

Side Note: Boolean

@craigphicks I don't think your examples 1.2 and 2.3 do what you intend. My understanding is that would match filter(new Boolean()) (since new Boolean() instanceof Boolean === true) when I believe you are intending to match filter(Boolean) (but Boolean instanceof Boolean === false).

@craigphicks
Copy link
Author

craigphicks commented Apr 16, 2024

@saltman424 - Actually I was just about to remove those Boolean examples. That can be achieved by changing the type of the Boolean types converter (the expando function without new).

@saltman424
Copy link

@craigphicks

I'm not sure if you are saying these should or should not transfer the instanceof:

I assumed it would transfer it. Basically, my interpretation is that const x: typeof y means x can be used anywhere that y can be used; or more simply, typeof y means exactly the type that y has, no alterations. Consider this example:

let a: instanceof A
a = ... as any as typeof a // this should always work, no matter the type of a

My gut feeling at this point is that typeof should maybe not be allowed because it is not clear if it should switch to structural, or keep instanceof. Carrying both could be a mistake because instanceof is generally sufficient and faster than a structural type check.

I think instanceof types will need to carry both the structural and instanceof information, as that is what would allow:

type AlwaysTrue<T extends object> = instanceof T extends T

We can't just use a nominal check in that case because consider this variation:

type AlwaysTrue<T extends object> = instanceof T extends Partial<T>

We need to be able to check instanceof T structurally against another type, so we need to carry the structural information as well. And with that being the case, I think it is reasonable for a developer to assume typeof will convey both the instanceof and structural type information about the variable being referenced

@saltman424
Copy link

typeof instanceof A is not valid since instanceof A is a type, and typeof can only be used on a value. Assuming you meant something like:

const a: instanceof A
type X = typeof a

Then X would be instanceof A as a has a type of instanceof A

@saltman424
Copy link

saltman424 commented Apr 16, 2024

@craigphicks I think I understand your point: TypeScript currently only has structural types, so right now, typeof a can equally be interpreted as "the structural type of a" or "the entire type of a". So if this proposal is enacted, there has to be a decision on which of those two interpretations is correct. With that being said, my response is basically:

  1. I think it would break a lot of things if typeof only retrieved the structural type information (see another example below)
  2. I can't think of anything that wouldn't work as expected if typeof included all of the type information
  3. I don't think it would be confusing to developers if the official position was: typeof x is just the type of x, whatever that type may be (structural or non-structural)

Another edge case if typeof didn't support non-structural types:

function f<T>(foo: T): void {
  let temp: typeof foo = foo
  // Today this works. Would this stop working because `T` might be an `instanceof` type, like in the call below?
  foo = temp
}

f<instanceof A>(new A())

@craigphicks craigphicks changed the title Proposal: Type-side instanceof keyword and functionality Proposal: Type-side use of instanceof keyword as an instance-query to allow instanceof checking Apr 29, 2024
@petermakeswebsites
Copy link

petermakeswebsites commented Oct 24, 2024

I would really appreciate this feature.

Using JS's instanceof is part of my workflow quite often. But when instanceof's are inside of functions, it's difficult or impossible to make an appropriate return type. Here's a very rudimentary version of the issue:

class Foo {
  baz: string
}

class Bar {
  baz: string
}

const something : Foo | Bar = new Bar()

// TypeScript clearly has the ability built-in to differentiate Foo from Bar under the hood when using instanceof
if (something instanceof Bar) {
  something // TypeScript correctly narrows to Bar
} else {
  something // Typescript correctly narrows to Foo
}

const errorIfBar<T>(obj : T) : ??? { // here something along the lines of `T excluding instanceof Bar` would suffice perfectly
  if (obj instanceof Bar) throw new Error("got a Bar!")
  return obj
}

Whatever TypeScript is doing under the hood from the if ( ... instanceof ... ) { .. } in terms of type narrowing, it would be very handy to be able to use that as some kind of utility function.

In the above example, when you want to explicitly specify a return type of a function that uses instanceof, structural typing doesn't work because, for example, Exclude<T, Bar> would exclude both because both Foo and Bar are the same shape.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Awaiting More Feedback This means we'd like to hear from more people who would be helped by this feature Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

4 participants