microsoft/TypeScript

Proposal: stronger compiler typing using Symbol

SimonMeskens opened this issue ยท 3 comments

Summary

In ES2015, by using Symbol, we can enforce an object to have a unique identifier. The reason we can only do duck typing in Typescript is because this wasn't possible in the past and thus, while compile-time strong typing was possible, it was nearly useless in actual use, because we couldn't test for it at run-time. My proposal is that Typescript gets a way to flag a class or an interface as strong, which generates a unique Symbol for that class/interface, allowing you to type test strongly at run-time.

The reason this would be optional on top of duck typing, is because duck types are a useful super-set of strong types (I'm using loose definitions here). Basically, if you take a Typescript type, add a unique ID and freeze it, you now have a strong type, like in, say, C#. In real life situations, duck types tend to be more useful, but there are a lot of use cases, where being able to strongly say "this object was created by this function" is an important trick. On top of that, programmers coming from strongly typed languages, like C#, might want to do certain things with types that aren't possible in a duck-typed world (like having two identical interfaces with different names, used as identifiers or categories).

This proposal is in line with the direction of ES2015, check out Symbol.hasInstance for more info. The proposal merely provides an extra richness of type checking on top of what ES2015 is already doing today.

ES2015 Example

var MyStrongClassSymbol = Symbol('MyStrongClass');

class MyStrongClass {
  constructor() {
    this[MyStrongClassSymbol] = true;
    Object.freeze(this);
  }

  static isMyStrongClass(o) {
    return !!o[MyStrongClassSymbol];
  }
}

class NotMyStrongClass {}

var strong1 = new MyStrongClass();
console.log(MyStrongClass.isMyStrongClass(strong1)); // outputs "true"
var strong2 = new NotMyStrongClass();
console.log(MyStrongClass.isMyStrongClass(strong2)); // outputs "false"

Typescript proposed syntax

By using the typeof reserved keyword as an attribute, we create a situation that is not valid in ES2015 (and never will be), that can be compiled away into the desired behavior. On top of that, I think the syntax is pretty clear in intent.

interface IGreeting {
    typeof: IGreeting;

    greet(): string;
}

class Greeter implements IGreeting {
    typeof: Greeter

    greeting: string;
    constructor(message: string) {
        this.greeting = message;
    }
    greet() {
        return "Hello, " + this.greeting;
    }
}

Code Generation

Notice how I encapsulate the unique symbols, so they are only accessible from the class or interface itself. This way, we get a little extra safety at run-time. I kept the implementation simple for now, there might be some speed tweaks. What is currently lacking is a better way to add an interface to a class and possibly a way to make this interop better with the Reflect utilities. It is completely functional however.

var IGreeter = (function () {
    var type = Symbol("IGreeter");

    var IGreeter = function(instance) {
        instance[type] = true;
    };

    IGreeter.isInterfaceOf = function(instance) {
        return instance[type] === true;
    }

    // For newer browsers, this will make instanceof work
    IGreeter[Symbol.hasInstance] = IGreeter.isInterfaceOf;

    return IGreeter;
} ());

var Greeter = (function () {
    var type = Symbol("Greeter");

    class Greeter {
        constructor(message) {
            this.greeting = message;

            Object.freeze(this);
        }

        greet() {
            return "Hello, " + this.greeting;
        }

        static isInterfaceOf(instance) {
            return instance[type] === true &&
                Greeter.prototype.isPrototypeOf(instance);
        }
    };

    // For newer browsers, this will make instanceof more strict
    Greeter[Symbol.hasInstance] = Greeter.isInterfaceOf;

    IGreeter(Greeter.prototype);
    Greeter.prototype[type] = true;

    Object.freeze(Greeter.prototype);

    return Greeter;
} ());

Discussion

Here's some discussion points I came across while working on the proposal:

Right now I froze both the object and its prototype. I feel like without freezing, a strong type isn't as useful. I can see use cases where people want just unique identification though. On the other hand, they can easily implement this themselves. The strong compiler checking would only be for both.

Do you feel like isInterfaceOf should also ducktype check the interface implementation? If the objects are frozen, this is probably unnecessary and for large interfaces / class interfaces, this might be slow. Are there use cases where you want to use instanceof lots of times per second?

Should we add the type symbols to metadata for better Reflect interop? If so, how would you implement this?

Hey @SimonMeskens, thanks for taking the time to write this up. While supporting nominal typing is certainly something we've thought about a lot (#202), unfortunately I don't think that this is something we'd be willing to take on.

Firstly, we avoid any sort of type-directed emit. Specifically, it's one of our non-goals:

Non-goals

Add or rely on run-time type information in programs, or emit different code based on the results of the type system.

Second, I don't think we should be extending ES class syntax for this, especially when you can achieve functionally the same thing by "branding" a type with a tag property, just like you mentioned:

// In an interface
interface MyInterface {
    _myInterfaceTag?: undefined;
}

// Or in a class
class MyClass {
    private _myClassTag undefined;
}

Additionally the proposed syntax breaks valid code (not so much the issue, it's just a matter of picking a different syntax), and it's not clear what to do if Symbols aren't available on a given runtime.

Hopefully in the future we can make it easier to have nominal types, but I don't think we can do any special emit based on those types.

Hey @DanielRosenwasser, thanks for the reply. I assumed my proposal was out of the current intent of the language, which is why I made a decent proposal, not to waste much time on might-be's and just discuss possibilities.

I agree that tag properties as identifiers is a good solution to work with (hence why my emits were based on them). One more question if you don't mind: do you think that we could get type checking for custom Symbol named properties? This way, we can make these tags unique. I'd have to draft a new proposal just for this feature, but I expect it'd work similar to how we can use specific strings as types. The issue is that while a newly defined Symbol is nominal for all intents and purposes, to actually use it, we have to store it in a variable. Would this syntax work?

const id = Symbol("id");

interface TaggedInterface {
    [id]: true;
}

Would it be interesting to propose this?

I went digging into what is possible and I already found my own solution, though I still think in the future, we should get a way of using user-defined symbols in computed properties.

For others reading this in the future, you can use Symbol.species on the prototype. The spec only talks about what to do when it is added to the constructor, not the prototype, so this is spec compliant (and semantic):

interface ITaggedInterface {
    [Symbol.species]: ITaggedInterface;
}

class TaggedClass implements ITaggedInterface {
    [Symbol.species] = TaggedClass.prototype;
}

I'll leave implementations of isInterfaceOf and Symbol.hasInstance to the reader ๐Ÿ˜‰