Skip to content

Suggestion: the 'instanceof' type modifier for class and function types #8316

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

Closed
malibuzios opened this issue Apr 26, 2016 · 7 comments
Closed
Labels
Revisit An issue worth coming back to Suggestion An idea for TypeScript

Comments

@malibuzios
Copy link

malibuzios commented Apr 26, 2016

[A search yields this addresses #7271. However, I found that out only after it was written, but still decided to post it separately, as I felt it wouldn't gain enough exposure there]

Since TS classes are purely structural, there is no current way to precisely model the instanceof runtime expression, which is the idiomatic way that instances of classes are tested at runtime:

class A {
    prop: number;
}

class B {
    prop: number;
}

function giveMeA(x: A) {
    if (!(x instanceof A))
        throw new TypeError("This is not A!");
}

giveMeA(new A()); // Compiles and works
giveMeA(new B()); // Compiles but errors in runtime: "This is not A!"

Proposed solution

instanceof T, where T is a class or possibly a function type, is a strict subtype of T that only includes the nominal reference to T, or any one of its nominal subtypes, and excludes all other structurally compatible types.

It is used as follows:

class A {
    prop: number;
}

class B {
    prop: number;
}

function giveMeA(x: instanceof A) {
    if (!(x instanceof A))
        throw new TypeError("This is not A!");
}

giveMeA(new A()); // Compiles and works
giveMeA(new B()); // Compilation error: "[instanceof] B is not assignable to type 'instanceof A'"

Implicit narrowing through assignment and flow analysis

In most cases, there wouldn't be a need to explicitly state let x: instanceof A as it would be implicitly inferred through code analysis:

let x = new A(); // Type of 'x' is 'instanceof A'

x = new B(); // Type of 'x' has now been widened back to 'A' 
             // or alternatively implicitly changed to 'instanceof B'!

The instanceof runtime operator will now be used as a guard that will also cause the type to be narrowed:

declare var x: A; // Type of 'x' is 'A' - it cannot be narrowed as not enough info is available
declare var y: B; // Type of 'y' is 'B'

if (x instanceof A) {
    // Type of 'x' is now narrowed to `instanceof A`

    x = y; // Type of 'x' is now widened back to 'A'

}
else if (x instanceof B) {
    // Type is now narrowed to `instanceof B`

    x = y; // Type of 'x' is now widened to 'A' (not 'B')
} 

Application on plain constructor functions

The same behavior may also be given to regular functions that are used as constructors with the new keyword (I'm using some proposed syntax here for illustration):

function A(this: { prop: number }) {
    this.prop: = 1;
}

function B(this: { prop: number }) {
    this.prop: = 2;
}

function giveMeA(x: instanceof A) {
    if (!(x instanceof A))
        throw new TypeError("This is not A!");
}

giveMeA(new A()); // Compiles and works
giveMeA(new B()); // Compilation error: 'instanceof B' is not assignable to 'instanceof A'

Remarks

The more conceptual way to look at this is that it takes nominal subtyping and simply views it as a specialized subdivision of structural subtyping (similarly to the way, say, string literal types are a specialization of string types), and allows both to live together in a backwards compatible way, without requiring explicitly stating a class as particularly being 'nominal' or 'structural'. It doesn't require any compiler switches and most of the time would be effective implicitly through assignment and flow analysis.

I have no idea of the difficulty of implementing this, especially the analysis part, but I believe it might be possible, considering flow analysis has already been proven viable and integrated to the next release. I'm aware this may not turn out to be the 'ultimate' solution that would eventually chosen, but I thought this was interesting enough to share.

@malibuzios
Copy link
Author

malibuzios commented Apr 27, 2016

Some additional remarks after thinking about this further:

(1) A contextual narrowing can be made for parameter types of functions or methods that internally narrow the type to its instanceof counterpart before all other access to the instance is performed, here's an example:

function giveMeA(x: A) {
    if (!(x instanceof A))
        throw new TypeError("This is not A!");

    x.prop = 1234;
}

With flow analysis, the compiler can realize that the statement x.prop = 1234, which is the first [effective] reference to x in the body of the function, is only executed after the type has been effectively narrowed. Therefore it will implicitly narrow the parameter type itself to instanceof A. The implicit function signature would now look like:

function giveMeA(x: instanceof A) {

(2) A global compiler switch like --nominalClasses can still be made available. What it will effectively do is implicitly modify all explicit annotations for any given class type T (e.g. let x: T) to effectively mean instanceof T (e.g. let x: instanceof T) (this would also be reflected in the language service when the user highlights the variable). Inferred assignments like let x = new A() would permanently constrain x to only allow instanceof A types to be assigned to it, and would never implicitly widen the type.

(3) I'm aware that questioning prior design decisions is a somewhat of a 'taboo' subject here, but I must mention that if all classes were nominal in the first place, it would have been much easier to simply have a counterpart structureof modifier that would instantly convert any class to the corresponding interface:

let x: A;
let y: B;

x = y; // This wouldn't work if classes were nominal
let z: structureof A;
z = y; // OK..

Say a function wanted the exact class instance, but the user only had a structurally compatible one, they could simply use a cast. Which would be seen as a contravariant one:

declare function giveMeA(a: A);

let x = { prop: 1234 };
givaMeA(x); // Error: type '{ prop: number }' is not assignable to 'A' 
givaMeA(<A> x); // Works, as the cast is considered contravariant there is no need to use
                // something unsafe like '<any>' 

In the 3+ years I have been using TypeScript I believe there were very few occasions where I used a structural assignment between classes or between interfaces and classes. For the few cases I might have done that, I feel it would have been fine for me to use one of these solutions.

@kitsonk
Copy link
Contributor

kitsonk commented Apr 27, 2016

Also essentially a dupe of #8168 as well, related to #1719 and specifically discussion in #1719 (comment). Nominal typings is being dealt with under #202.

@malibuzios
Copy link
Author

@kitsonk

Thank you for tracking down similar issues and related comments, this is valuable information for the readers who may be interested in this. I don't feel that any of these would be a suitable place to present this particular approach.

The purpose of this is not particularly about introducing nominal typing. It is about creating an implicit nominal 'awareness' for classes (only) and prototype chains, the same way that string literal types are a form of a an awareness for the specialization of string type.

Just make sure that the discussion is not diverted to trivial matters like whether this is a 'duplicate' or not: I would not have spent the 5-6 hours it took to investigate, write and edit this if it was 'buried' in a closed or old issue that very few people are exposed to.

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript In Discussion Not yet reached consensus labels Apr 27, 2016
@DanielRosenwasser
Copy link
Member

I think a good way to look at this is "use-site" nominal typing, as opposed to "declaration-site" nominal typing.

In other words, instead of deciding on whether a class itself is nominal or not when authoring it, consumers need to assert that they really only mean to use the nominal class. This is a little more cumbersome, but it does definitely leave room open for flexibility when using your classes.

@malibuzios
Copy link
Author

malibuzios commented Apr 29, 2016

Hi, I wrote these comments several days ago but felt I haven't managed to thoroughly research and figure them out completely. I've decided to post them anyway:

(1): I must admit that at first I actually felt this was somewhat superfluous (despite the fact that I proposed it myself..) because it seems like having all classes to become nominal looks like a dramatically simpler way to achieve an equivalent or better level of type safety. However, now I realize there is more to this:

When instanceof A is used instead of just A this would, in a way, "force" the programmer to apply a run-time check to satisfy the type (unless they want to use a cast, which is less safe but also possible):

declare function iWantInstanceOfA(a: instanceof A);

function iOnlyCareAboutTheStructureOfA(a: A) {
    iWantInstanceOfA(a); // Error: type 'A' is not assignable to 'instanceof A'

    // which is solved either by a guard:
    if (a instanceof A)
        iWantInstanceOfA(a); // OK

    // or by a less safe, contravariant cast:
    iWantInstanceOfA(<instanceof A> a) // OK
}

So in a way having "less strict" typing for classes in general, requires more 'work' for the programmer to try to satisfy a type that is 'stricter than usual'. That's interesting..

(2): Rather subtle point:

declare let x: A;

if (x instanceof A) {
    // 'x' gets type 'instanceof A'
}
else if (x instanceof B) {
    // 'x' gets type 'instanceof B'
}
else {
    // But what happens here? does it resort back to 'A'?
}

For maximum percision, the else block can get the subtraction type A - (instanceof A | instanceof B). It might seem a bit unnecessary to be so percise, but one subtle effect of this is that it would prevent an otherwise reasonable contravariant cast in the else block (or with flow analysis, it could be the rest of of the execution block, as demonstrated in the next point):

else {
    let y = <instanceof A> x; // Error.. type 'A - (instanceof A | instanceof B)' cannot 
                              // be directly cast to type 'instanceof A'
}

(3): Another issue is on how to get more precise and useful flow analysis, and more correctly model the set of possible instances and the effects of the instanceof guards. Preferably achieving the same fine-grained analysis that is currently done for union types.

function f() {
    if (x instanceof A || x instanceof B) {
        return;
    }

    // say 'x' gets the precise type 'A - (instanceof A | instanceof B)':

    if (x instanceof A) {
        // 'x' should ideally get type 'nothing', but does the subtraction type really help here?
    }
    else if (x instanceof B) {
        // same here..
    }
}

(4): My next point here was supposed to be an attempt to try to improve the (very rudimentary and rather inaccurate) heuristic I tried to suggest for the 'back propagation' of flow analysis from the function's body to the signature itself - it essentially tries to analyze the intention of the programmer on whether they want the structural or the more specific nominal type. It turned out this was more difficult than I initially thought. However a good solution for this may turn out to be useful in other areas. I'm still working on it.

@mhegazy
Copy link
Contributor

mhegazy commented May 6, 2016

As noted in #8503, we will be fixing the instanceof checks to work nominally for classes (as they should). This work is tracked by #7271. Fixing #7271 should eliminate the immediate need for this proposal.

The general issue of how classes are compared, and whether they should be nominal or structural, is something that we have got over the years. We believe that adding an explicit tagging support for classes/types declaration to switch them to nominal treatment is the way this request would be best met, rather than doing it at use sites. This is best tracked by: #202.

@mhegazy
Copy link
Contributor

mhegazy commented May 6, 2016

Possibly revisit this decision when investigating #202.

@mhegazy mhegazy closed this as completed May 6, 2016
@mhegazy mhegazy added Revisit An issue worth coming back to and removed In Discussion Not yet reached consensus labels May 6, 2016
@microsoft microsoft locked and limited conversation to collaborators Jun 19, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Revisit An issue worth coming back to Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

5 participants