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

Singleton types under the form of string literal types #1003

Closed
Nevor opened this issue Oct 30, 2014 · 68 comments
Closed

Singleton types under the form of string literal types #1003

Nevor opened this issue Oct 30, 2014 · 68 comments
Assignees
Labels
Domain: Literal Types Unit types including string literal types, numeric literal types, Boolean literals, null, undefined Fixed A PR has been merged for this issue Suggestion An idea for TypeScript

Comments

@Nevor
Copy link

Nevor commented Oct 30, 2014

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 */
}
@ahejlsberg ahejlsberg added the Suggestion An idea for TypeScript label Oct 30, 2014
@ahejlsberg
Copy link
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;
}

@DanielRosenwasser
Copy link
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.

@RyanCavanaugh
Copy link
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'

@ahejlsberg
Copy link
Member

@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).

@Nevor
Copy link
Author

Nevor commented Oct 31, 2014

I have updated my prototype to accept the first program of @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

@DanielRosenwasser
Copy link
Member

@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
}

@Nevor
Copy link
Author

Nevor commented Oct 31, 2014

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
} 

@DanielRosenwasser
Copy link
Member

@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.

@Nevor
Copy link
Author

Nevor commented Nov 5, 2014

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.

@kevinbarabash
Copy link

@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.

@Nevor
Copy link
Author

Nevor commented Dec 3, 2014

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.

@danielearwicker
Copy link

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?

@DanielRosenwasser
Copy link
Member

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

@aldonline
Copy link

This thread is simply amazing.

  1. Besides @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.

@danielearwicker
Copy link

@DanielRosenwasser Sure. I guess what I'm saying is that by stating that myFactory is a CanaryFactory, I have explicitly requested that RTTI. By adding the literal string type to CanaryFactory, I've turned it into an alias for requesting that RTTI on anything that implements CanaryFactory, in that my program won't compile without it, but I have to repeat it. Unlike an interface method (where the body varies between implementations), this must always have the same "implementation". It's implied.

So It's not the requesting it I'm wondering about, it's needing to request it twice.

In which I have to say beetlejuice three times:

interface Beetlejuice {
    kind: "beetlejuice";
}

class MichaelKeaton implements Beetlejuice {
    kind: "beetlejuice" = "beetlejuice";
}

console.log(new MichaelKeaton().kind);

If I leave out the assignment = "beetlejuice", the program prints undefined. Perfectly consistent (if confusing at first glance), because we're writing a class, not an object literal, so in this context the colon means "of type", not "initialised with". It's also consistent (if unnerving) that it still compiles in that state: class members are always implicitly optional (and cannot be explicitly/redundantly declared optional), even when implementing a property that is non-optional. It just seems like quite a ceremony, albeit a perfectly consistent one.

BTW if I just assign without declaring the type:

class MichaelKeaton implements Beetlejuice {
    kind = "beetlejuice";
}

Then:

Types of property 'kind' are incompatible.
    Type 'string' is not assignable to type '"beetlejuice"'.

Is that just a glitch in the prototype? Assignment at the point of introducing a property decides its type, and I guess that process isn't influenced by the interface's requirements.

@DanielRosenwasser
Copy link
Member

@danielearwicker, first off, major 👍 for your example. Clearly we don't want anyone writing "Beetlejuice" three times or else we'll end up singing the banana boat song. ;)

The syntax within a class is a good point. In some sense, one could argue people shouldn't be using classes in that fashion - a big idea behind OO is that you can tackle the other side of the expression problem. Still, a tag check can be much faster, is often more convenient, and you raise a good point - there's some obviously redundant information there in the type, and it's probably an error to omit the instantiation.

I think such a suggestion might warrant its own issue, separate from (but contingent on the acceptance of) this one. For instance, what would we do in the following case?

class SchrödingerCat {
    state: "Dead" | "Alive"
}

@Nevor
Copy link
Author

Nevor commented Dec 15, 2014

@danielearwicker about your question on incompatible types, this is by design. The type inference for your field kind is done on the assignation and then the inferred type of the class is matched with expected types (here the interface Beetlejuice). For backward compatibility I have chosen to widen literal types when inferring assignations. This is to avoid this kind of problem :

var foo = "foo";

if(pred) {
  foo = "bar"; // would not compile with type error
}

// the same applies to classes

class Bar {
   name  = "unknown";
}

new Bar().name = "John"; // would give also a type error

Without this limitation, a lot of currently valid Typescript would not compile. A solution would be to infer types on all usages of a variable. An other solution would be to use some trick of keeping two types (string and literal) and then widen on usage. Both of these would require a complete rework of Typescript's type checker, this is another issue on itself.

@danielearwicker
Copy link

@DanielRosenwasser Me say day!

This feature is already excellent - I have had great results writing a spec of a JSON dialect that specifies queries, made of nestable expressions. So you're right, this repeating-myself issue should be separate. It's a minor thing.

Re: "Dead" | "Alive", a union type doesn't have a unique thing to be auto-initialized to (by definition) so I wouldn't expect any auto-initialization to happen. It only makes sense for a simple string-literal type, which has this interesting property that it can only be initialised one way - well, apart from null, undefined and absolutely anything with an <any> in front of it! But only one overwhelmingly useful way.

@Nevor - Thanks. That makes sense for ordinary variables and new properties introduced in a class. But what about a class property P = "P" that has the same name as a property P: "P" defined in an interface that the class is marked as implementing? Does the current structure of the compiler make it hard for it to deduce that P is already constrained to "P" and therefore should not (indeed, cannot) be widened to "string"?

@fdecampredon
Copy link

👍
having this feature would be invaluable, I'm sorry to ask that but is it scheduled for a typescript version ?

@danquirk
Copy link
Member

danquirk commented Feb 3, 2015

We still need to talk about it a bit further but the idea was well liked. At the moment our near term schedule is fairly full with ES6 related features though.

@dead-claudia
Copy link

Would this work with my proposal, particularly with the const enum feature? To take the first few lines of the initial overview as an example:

const enum Result : string {
  ok,
  fail,
  abort,
}

function compute(n : number) : Result {
  if(...) {
    return Result.ok;
  } else if (...) {
    return Result.fail;
 } else {
    // This couldn't be any more clear what the problem is.
    return Result.crash;
 }
}

@dead-claudia
Copy link

Although now that I come to think about it, this could complement enum types, as enums are effectively a type union of constants. Enums are typed like classes, and type unions would be the duck-typing equivalent.

Also, shouldn't this be expanded for other primitive constants as well? I know of a few use cases for numeric constants, and a few cases where false is treated specially, while true is ignored or not used.

@DanielRosenwasser DanielRosenwasser removed the Help Wanted You can do this label Oct 8, 2015
@DanielRosenwasser DanielRosenwasser modified the milestones: TypeScript 1.7, Community Oct 8, 2015
@DanielRosenwasser DanielRosenwasser self-assigned this Oct 8, 2015
@mhegazy mhegazy modified the milestones: TypeScript 1.7, TypeScript 1.8 Oct 9, 2015
@knuton
Copy link

knuton commented Oct 29, 2015

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

@mhegazy
Copy link
Contributor

mhegazy commented Dec 1, 2015

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.

@Elephant-Vessel
Copy link

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;
}

@DanielRosenwasser DanielRosenwasser added the Domain: Literal Types Unit types including string literal types, numeric literal types, Boolean literals, null, undefined label Apr 5, 2016
@DanielRosenwasser
Copy link
Member

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

@microsoft microsoft locked and limited conversation to collaborators Jun 18, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Domain: Literal Types Unit types including string literal types, numeric literal types, Boolean literals, null, undefined Fixed A PR has been merged for this issue Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests