Skip to content

Singleton types under the form of string literal types #1003

Description

This proposal is based on a working prototype located at https://github.com/Nevor/TypeScript/tree/SingletonTypes

String literal types extended to the whole language

This change would bring singleton types created from literal string to complete recent addition of type aliases and union types. This would hopefully satisfy those that mentioned string enum and tagged union in previous PRs (#805, #186).

This addition would be short thanks to a concept that was already implemented internally for ".d.ts" files.

Use cases

Often operational distinction of function and values are done with an companion finite set of string that are used as tags. For instance events handler types rely on strings like "mouseover" or "mouseenter".

Sometimes string are often themselves operating on a finite definite set of values that we want to convey this through specifications and have something to enforce it.

And for more advanced typing, we sometimes use types themselves as informations to guide function usages, constraining a little further the base types.

Current workarounds

There is no way to create string enum for now, the workaround is to manipulate variables assigned once and for all. This does not protect from typos that will gladly propagate everywhere a string is used.

When we want to implement tagged union types for symbolic computation, we must use some number enum coupled with subtyping and casting machinery, losing all type safety.

In general, advanced type constraint are done through classes and this put us further away from simple records that would have been used in javascript.

Overview examples

type result = "ok" | "fail" | "abort";

// "string enum"
function compute(n : number) : result {
  if(...) {
    return "ok";
  } else if (...) {
    return "fail";
 } else {
    return "crash"; // Error, not accepted
 }
}

function checkSuccess(o : result) : void { ... }

var res : result = compute(42);

checkSuccess(res); // OK
checkSuccess("crash"); // Error

res = "crash"; // Error

var message = res; // OK, message infered as string
var verbose = "Current status : " + res; // OK


// Specifications constrains
interface operationAction {
  name : string;
  id : number;
  status : result;
}

// Usable as and with regular types
var results : result[] = [compute(3), compute(27), "ok"]; // Ok
results = ["crash", "unknown"]; // Error

type error_level = "warning" | "fatal";

interface Foo<T> {
  foo : T;
}
interface Toto<U> {
  value : Foo<result> | { unknown : string; value : U };
}

var foo : Toto<error_level> = { foo : "ok" }; // OK
var foo : Toto<error_level> = { foo : "crash" }; // Error
var foo : Toto<error_level> = { unknown : "Unknown error", value : "warning" }; // OK
var foo : Toto<error_level> = { unknown : "Unknown error", value : "trace" }; // Error


// Disjoint union
type obj = { kind : "name"; name : string } | { kind : "id"; id : number } | { kind : "internal" ; id : number }

var o : obj = { kind : "name", name : "foo", id : 3 } 
var o : obj = { kind : "id", name : "foo", id : 3 } 
// Both object are strictly distinguished by their kind

var o : obj = { kind : "unknown", name : "something" }; // Error

type classical_obj : { name : string } | { id : number };

var c_o : classical_obj = { name : "foo", id : 3 }; // A supertype of both is assignable, we lose infos 

Typing specifications

  • any string literal as type "s" (StringLiteralType) where s is the content of the literal.
  • every StringLiteralTypes are subtypes of string, the reverse is not true.
  • two StringLiteralTypes are compatible if and only if they represent the same string literal.
type ok = "ok"


var a : ok = "ok";
var b : "ok" = a; // OK

var a  : ok = "no"; // Error
var c : string = a; // OK 
var a : ok = c; // Error 

Pitfalls and remaining work

Backward compatibility

This prototype is backward compatible (accepts programs that were accepted before) but in one case :

function ... {
  if (...) { 
    return "foo";
 } else {
    return "bar";
 }
}

The compiler will raise an Error saying that there is no common type between "foo" and "bar". This is because the compiler only accept one of the return type to be supertype and does not widen before.
We might add a special case for StringLiteralTypes and keep other types as is, or, do some widening and therefore accept empty records for conflicting records for instance.

Error messages

It might confuse users that their literal strings are mentioned as types when they are expecting to see "string" even though this difference as no incidence on normally rejected string.
The compiler might display StringLiteralTypes as "string" whenever the conflict is not involved between two StringLiteralTypes.

Extending type guards

To be fully usable to distinguish records by type tags, type guards should be extended to take into account this kind singleton types. One would expect the following to work :

type obj = { kind : "name"; name : string } | { kind : "id" ; id : number };
var o : obj = ...;
if (o.kind == "name") {
  /* o considered as left type */
} else if(o.kind == "id")  {
  /* o considered as right type */
}

Activity

  1. ahejlsberg commented on Oct 30, 2014

    @ahejlsberg
    Member

    I _really_ like the idea of creating tagged unions though literal types.

    interface Square {
        kind: "square";
        size: number;
    }
    interface Rectangle {
        kind: "rectangle";
        width: number;
        height: number;
    }
    interface Circle {
        kind: "circle";
        radius: number;
    }
    type Shape = Rectangle | Circle | Line;
    function area(s: Shape) {
        if (s.kind === "square") return s.size * s.size;
        if (s.kind === "rectangle") return s.width * s.height;
        if (s.kind === "circle") return Math.PI * s.radius * s.radius;
    }

    Per already existing rules for members of union types, the type of s.kind would be

    "square" | "rectangle" | "circle"

    Type guards could relate the common kind property to the corresponding object type in the union and narrow the type appropriately in the guarded block.

    This idea could extend equally well to const enum types from #970.

    const enum ShapeKind { Square, Rectangle, Circle }
    interface Square {
        kind: ShapeKind.Square;
        size: number;
    }
    interface Rectangle {
        kind: ShapeKind.Rectangle;
        width: number;
        height: number;
    }
    interface Circle {
        kind: ShapeKind.Circle;
        radius: number;
    }
    type Shape = Rectangle | Circle | Line;
    function area(s: Shape) {
        if (s.kind === ShapeKind.Square) return s.size * s.size;
        if (s.kind === ShapeKind.Rectangle) return s.width * s.height;
        if (s.kind === ShapeKind.Circle) return Math.PI * s.radius * s.radius;
    }
  2. DanielRosenwasser commented on Oct 30, 2014

    @DanielRosenwasser
    Member

    👍 for using const enum types as the discriminator, this is the most ideal scenario for us.

    I've wanted something like this since we first got union types. The problem is that much of our compiler uses many-to-many relationships between discriminators and types.

    interface UnaryExpression extends Expression {
        kind: SyntaxKind.PrefixExpression | SyntaxKind.PostfixExpression;
        operator: SyntaxKind.PlusToken | SyntaxKind.MinusToken | SyntaxKind.TildeToken | /*...*/ | SyntaxKind.VoidKeyword;
        operand: Expression;
    }
    
    interface BinaryExpression extends Expression {
        kind: SyntaxKind.BinaryExpression
        left: Expression;
        operator: SyntaxKind.PlusToken | SyntaxKind.MinusToken | /*...*/ | SyntaxKind.AsteriskToken;
        right: Expression;
    }
    
    function doSomething(expr: Expression) {
        switch (expr.kind) {
            case SyntaxKind.PrefixExpression:
            case SyntaxKind.PostfixExpression:
                /* 'expr' is a 'UnaryExpression' here */
                break;
            default:
                /* 'expr' is still an 'Expression' */
        }
    
        switch (expr.operator) {
            case SyntaxKind.AsteriskToken:
                /* 'expr' is a 'BinaryExpression' here */
                break;
            case SyntaxKind.TildeToken:
                /* 'expr' is a 'UnaryExpression' here */
                break;
            case SyntaxKind.PlusToken:
                /* 'expr' is a 'UnaryExpression | BinaryExpression' here */
                break;
            default:
                /* 'expr' is still an 'Expression' */
        }
    }

    This isn't a problem from a usability standpoint, but rather, it's probably difficult to implement, though I haven't thought much about it. It's probably worth limiting the scope on something like this anyhow.

    Edited to reflect exclusivity of case clauses.

  3. RyanCavanaugh commented on Oct 30, 2014

    @RyanCavanaugh
    Member

    This also gives us a very easy way to reason about the desirable memberof operator, as its definition would simply be "the union type consisting of all the property names of the given type":

    interface MyModel {
      name: string;
      type: number;
      isReady: boolean;
    }
    
    // ember.d.ts
    function get<T>(model: T, memberName: memberof T): any { }
    
    var m: MyModel;
    get(m, 'isready'); // Error, cannot convert 'isready' to 'name' | 'string' | 'isReady'
  4. ahejlsberg commented on Oct 30, 2014

    @ahejlsberg
    Member

    Daniel Rosenwasser (@DanielRosenwasser) It's not that difficult to implement, a lot of the infrastructure is already there. As your example highlights, we would want type guards to also support switch statements. Again, not too complicated.

    One thing that is interesting is that Expression would no longer be a base type, but rather a union type of all possible expression types. And those expression types would no longer inherit a kind: SyntaxKind property, they would all have to introduce it themselves (with a particular set of values).

  5. Nevor commented on Oct 31, 2014

    @Nevor
    Author

    I have updated my prototype to accept the first program of Anders Hejlsberg (@ahejlsberg). The type guards where extended as such :

    For any predicate of the form ident.prop === expr with ident of union type and expr of literal type, ident is narrowed to retain only types that are assignable to { prop : typeof expr } if typeof expr is smaller that typeof prop.

    type Ready = { kind : "ready" };
    type Finished = { kind : "finished"; result : number };
    type Aborted = { kind : "aborted" ; error : string };
    type Status = Ready | Finished | Aborted;
    
    var s : Status;
    if(s.kind === "ready") {
      // treated as Ready 
     } else {
      // treated as Finished | Aborted
      if(s.kind == "aborted") {
        // treated as Aborted
        console.log(s.error);
      }
    }

    The commit of these changes is there : Nevor@d288ece

  6. DanielRosenwasser commented on Oct 31, 2014

    @DanielRosenwasser
    Member

    Roven Lamar Gabriel (@Nevor) very cool! What do you do in the following case?

    type Ready = { kind: "ready" };
    type ReallyReady = { kind: "ready", howReadyAreYou: string }
    /* ... */
    type Status = Ready | ReallyReady | Finished | Aborted;
    
    var s : Status;
    if(s.kind === "ready") {
        // treated as ???
     } else {
        // stuff
    }
  7. Nevor commented on Oct 31, 2014

    @Nevor
    Author

    In this case ˋsˋ will be treated as Really | ReallyReady and indeed we can't do nothing more with it.

    For the sake of demonstration we can imagine this instead :

    type Ready = { kind : "ready" ; really : "no" };
    type ReallyReady = { kind : "ready" ; really : "yes"; howMuch : number };

    and then use two guards either separated or with the already supported operator &&

    if(s.kind === "ready") {
      // here Ready | ReallyReady
      if(s.really === "yes") {
        // here ReallyReady
      }
    }
    
    if(s.kind === "ready" && s.really === "yes") {
      // here ReallyReady
    } 
  8. DanielRosenwasser commented on Nov 3, 2014

    @DanielRosenwasser
    Member

    Roven Lamar Gabriel (@Nevor) that is really awesome. I think we may need to discuss it a bit tomorrow/in the upcoming week, but given that this brings us pretty close to generalized user-defined type discriminators, I like it a lot.

  9. Nevor commented on Nov 5, 2014

    @Nevor
    Author

    While working on switch/case guards, I have realized that my algorithm for if-then-else guards was wrong, it did not take into account variance of properties, only variance of record width. The guards were therefore too strong in there constrain. So I have switched to a filter that directly compares properties type in union of records.

    Switch/Case guards

    That being said, I have been able extend my prototype with switch/case guards. This implementation narrows guarded types as expected while supporting fall through and mixed types as demoed bellow.

    Guarding and fall through :

    type Ready = { kind : "ready" };
    type Finished = { kind : "finished"; result : number };
    type Aborted = { kind : "aborted" ; error : string };
    type Status = Ready | Finished | Aborted;
    
    var s : Status;
    swtich (s.kind) {
      case "aborted": 
         // v as Aborted
      case "finished":
         // v as Aborted | Finished
         break;
       case "ready":
         // v as Ready
    }

    Mixed types and fall through :

    type Number = { value : number };
    type Null = { value : "null" };
    type Special = { value : "special" };
    type Value = Number | Null | Special;
    
    var v : Value;
    switch (v.value) {
       case "null" :
         // v as Null
       case 0:
         // v as Null | Number
         break;
       case 3:
         // v as Number | Special
         break;
       case "special":
         // v as Special
       case 10:
         // v as Number
       case default :
         // v as Number
    }

    One might ask what happens when there is nothing left to consume in the union-type in either the if-then-else or switch/case guard :

    type Value = { value : "value", content : string };
    var v : Value;
    
    if(v.value === "value") {
      ....
    } else {
       v.content // ???
    }
    
    type Ready = { value : "ready" };
    type NotReady = { value : "notready" };
    type Value = Ready | NotReady;
    
    var v : Value;
    switch (v.value) {
       case "ready":
         break;
       case "notready":
         break;
       case "ready": ???
         break;
    }

    In the current implementation the empty union type is transformed to the type void, restricting any use of the guarded type as expected. This give us a free alarm on dead code where a union type totally consumed will have an empty type with which nothing can be done.

    Further more, if we declare the accidentally allowed function impossible(v : void) { throw "impossible"; }, we have just crafted a cheap and javascript compatible exhaustiveness checker :

    switch (v.value) {
       case "ready":
         break;
       case "notready":
         break;
       default:
          impossible(v); // okay as long as the type is not extended
    }

    Another solution would be to consider the type as full or any.

    Implementation and break limitation

    The discussed changes can be found in the commits Nevor@5c8c82d, Nevor@d82f8f5 and Nevor@fd5c401.

    For simplicity the fall through is broke only in presence of a break or return as last statement of a case clause, extending the code to visit all execution paths should not be difficult.

  10. k4b7 commented on Dec 2, 2014

    @k4b7

    Roven Lamar Gabriel (@Nevor) Can you use these new types with function overloading? e.g.

    type mouseEvents = "mouseup" | "mousedown" | "mousemove";
    type pointerEvents = "pointerup" | "pointerdown" | "pointermove";
    
    function simulate(element: EventTarget, event: mouseEvents);
    function simulate(element: EventTarget, event: pointerEvents);
    function simulate(element: EventTarget, event: string);
    function simulate(element: EventTarget, event: string) {
       if (mouseEvents.test(event)) {
           // create a mouse event
       } else if (pointerEvents.test(event)) {
           // create a pointer event
       }
    }
    

    Addition/Suggestion:
    I would be cool if we could add a .test() method (or some other syntax) which would do a runtime check. It shouldn't be too hard to emit a regex test since all the possible values for the type are known at runtime.

  11. Nevor commented on Dec 3, 2014

    @Nevor
    Author

    Yep, they can be used with overloading, when running your simplified code through our prototype we get this :

    type mouseEvents = "mouseup" | "mousedown" | "mousemove";
    type pointerEvents = "pointerup" | "pointerdown" | "pointermove";
    
    function simulate(element: EventTarget, event: mouseEvents);
    function simulate(element: EventTarget, event: pointerEvents);
    function simulate(element: EventTarget, event: string) {
       /* ...  */
    }
    
    var e : EventTarget;
    simulate(e, "mousedown"); // OK
    simulate(e, "pointerup"); // OK
    simulate(e, "foo"); // Type error
    simulate(e, 10); // Type error
    /* etc */

    I removed the event: string case to really show that the typing works. Overloading working is expected because string literal types already exist in Typescript declaration files, so our extension of this to implementation files shouldn't have broke overloading.

    Concerning the test function, yes it should not be too hard indeed but in more general way, it's joining the idea of user defined type guards proposed in this issue #1007, that might interest you.

  12. danielearwicker commented on Dec 12, 2014

    @danielearwicker

    Given:

    interface Factory {
        kind: string;        
    }
    
    interface Canary {
        sing(): string;
    }
    
    interface CanaryFactory extends Factory {
        kind: "canary";
        make(colour: string): Canary;
    }
    
    var myFactory: CanaryFactory = {
        kind: "canary",   //   <----------------- have to specify this
        make(colour) {
            return {
                sing() {
                    return "I'm a " + colour + " canary";
                }
            };
        }
    };
    

    It seems odd that in the object literal I assign to myFactory, having imposed the type CanaryFactory on it, I have to explicitly repeat kind: "canary". It's like a piece of mandatory boilerplate. Would it be possible to just omit it in the TS and generate it for me in the JS?

  13. DanielRosenwasser commented on Dec 13, 2014

    @DanielRosenwasser
    Member

    Daniel Earwicker (@danielearwicker) one of our aims is to avoid adding in runtime type information when it's not explicitly requested by the user.

  14. aldonline commented on Dec 13, 2014

    @aldonline

    This thread is simply amazing.

    1. Besides Roven Lamar Gabriel (@Nevor) 's fork, is there a more up to date codebase with a working implementation ( however buggy )?
    2. What are the chances of this feature making it into a release? I don't see any mentions of this in the roadmap. We would rely on this heavily for a large project that's about to get started. I am willing to take the risk and I don't mind working with the cutting edge as long as there is a relatively high chance of this making its way up eventually.
  15. 55 remaining items

  16. modified the milestones: TypeScript 1.7, on Oct 8, 2015
  17. knuton commented on Oct 29, 2015

    @knuton

    I see that Daniel Rosenwasser (@DanielRosenwasser) is now working on this. Any way we can help with community contributions?

  18. mhegazy commented on Dec 1, 2015

    @mhegazy
    Contributor

    The original issue is addressed by #5185. the remaining part is extending the type guards to support the string literal types. but this should be tracked by a different issue.

  19. Elephant-Vessel commented on Apr 5, 2016

    @Elephant-Vessel

    Hey, shouldn't this result in a warning about either unreachable code or type incompatibility?

    type Animal = "dog" | "cat";
    
    function DoStuff(animal:Animal) {
        if(animal === "fish")
            return;
    }
  20. DanielRosenwasser commented on Apr 5, 2016

    @DanielRosenwasser
    Member

    Elephant-Vessel we're working on it. See #6196, though I don't know if we'll see it in 2.0.

  21. locked and limited conversation to collaborators on Jun 18, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Domain: Literal TypesUnit types including string literal types, numeric literal types, Boolean literals, null, undefinedFixedA PR has been merged for this issueSuggestionAn idea for TypeScript

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions