microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.44k stars 12.42k forks source link

Singleton types under the form of string literal types #1003

Closed Nevor closed 8 years ago

Nevor commented 9 years ago

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

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 commented 9 years ago

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 commented 9 years ago

:+1: 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 commented 9 years ago

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 commented 9 years ago

@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 commented 9 years ago

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 : https://github.com/Nevor/TypeScript/commit/d288ece46155f550a99c0f22a0dacfb29848cb58

DanielRosenwasser commented 9 years ago

@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 commented 9 years ago

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 commented 9 years ago

@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 commented 9 years ago

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 https://github.com/Nevor/TypeScript/commit/5c8c82d79ba2eff3c02033540fa797e5c36d2791, https://github.com/Nevor/TypeScript/commit/d82f8f5bfc055be04d3bb7e4f69767ad7f35d626 and https://github.com/Nevor/TypeScript/commit/fd5c4017d7a98e0f035b35b05471c4d8da7aef49.

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 commented 9 years ago

@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 commented 9 years ago

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 https://github.com/Microsoft/TypeScript/issues/1007, that might interest you.

danielearwicker commented 9 years ago

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 commented 9 years ago

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

aldonline commented 9 years ago

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 commented 9 years ago

@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 commented 9 years ago

@danielearwicker, first off, major :+1: 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 commented 9 years ago

@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 commented 9 years ago

@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 commented 9 years ago

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

danquirk commented 9 years ago

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.

knuton commented 9 years ago

Thanks for working this out @Nevor, another big thumbs up for this suggestion! Our codebase could really benefit from this feature.

Most people coming from functional languages may first think of an addition closer to #186 (crazy thoughts of a TypeScript pre-processor were thought). But this tagged union (literally) approach seems much more idiomatic and a perfectly natural extension of TypeScript.

Are there any steps we could take to help with this? Any open questions to explore? What is missing?

jrsnyder commented 9 years ago

:+1:

RyanCavanaugh commented 9 years ago

Accepting PRs. With something like this, please include lots of tests with your PR :smile:

knuton commented 9 years ago

Yay! :grinning:

Can we clarify the intended scope? As far as I can tell, this issue brought up the following main ideas:

  1. Singleton types in the form of string literal types
  2. Singleton types in the form of const-enum members
  3. Allow type refinement by tag inspection (foo.kind === "name")
  4. Allow for switch-case statements to serve as type guards

Which of these are within scope for a suggested implementation? Does it make sense to tackle 1. and 2. together? Should 3. be treated as part of #1007?

RyanCavanaugh commented 9 years ago

1 and 3 only, I think. 2 is probably separable; 4 is covered by #2388

JsonFreeman commented 9 years ago

We may consider this for boolean literal types as well. They could be very useful for generators, as per issue https://github.com/Microsoft/TypeScript/issues/2983

danielearwicker commented 9 years ago

I'm wondering whether this would allow forwarding to string literal overloads such as document.createElement. treating such overload sets like a reusable way to map runtime strings to compile-time interfaces, so for example, the caller can specify the type once (as a string literal) and have it checked both at compile and runtime:

function safeQuerySelector<TTagName /* constraint? */>(sel: string, tagName: TTagName) {

    // To discover what createElement would return, given string type tagName
    var elem = ((x?: void) => x ? document.createElement(tagName) : null)();

    // actually do the query
    elem = <typeof elem>document.querySelector(sel);

    // validate that the found element is of the desired type
    if (!elem || elem.nodeName !== tagName.toUpperCase()) {
        throw new Error(`Selector ${sel} does not identify a ${tagName} element`);
    }
    return elem;
}

var a = safeQuerySelector(".crazyLink", "a");
a.href = "blah"; // fine, as safeQuerySelector returned HTMLAnchorElement

But TTagName needs a constraint to ensure that it is a string. TTagName extends string is presumably wrong because it allows anything structurally compatible with string, which is not strict enough for DOM methods that want a real string.

Wouldn't be ideal to have to manually write down the union of all parameters acceptable to createElement:

function safeQuerySelector(
    sel: string,
    tagName: string|"a"|"abbr"|"acronym"|"address"|"applet" 
                    //... and so on for ~100 cases
JsonFreeman commented 9 years ago

@danielearwicker Actually, extends string would mean that it is really a string. Something structurally compatible with a string does not satisfy extends string. The one you are thinking of is extends String (note the capitalization). That is the structural one.

tinganho commented 9 years ago

I'm just wondering how it should be dealt with if we want to extend the type with more string literals.

Use case:

type Event = "click" | "drag" | "touch";
declare function addEventListener(event: Event); 
addEventListener("click");
createEvent("dbclick");
addEventListener("dblick"); // error
JsonFreeman commented 9 years ago

We don't have a way to augment a type declared by a type alias, whether or not it's a union type. You'd have to do what @jbondc suggested.

JsonFreeman commented 9 years ago

I'd like to clarify something about my interpretation of the proposal. Somebody please correct me if I'm wrong:

  1. A string literal in type position denotes the string literal type associated with the given text
  2. A string literal in a value position that is not contextually typed has type string
  3. A string literal in a value position contextually typed by a string literal type (or union thereof) has the string literal type associated with the text in the expression
  4. Unlike the relation between enums and number, string is neither assignable to nor a subtype of a string literal type.
JsonFreeman commented 9 years ago

(5). The apparent type of a string literal type is the String interface type.

JsonFreeman commented 9 years ago

No, you are talking about constant propagation, which is based on how a certain entity is declared (whether it has the const keyword or not determines whether it is a string literal type or not). I am talking about flowing this information via types, and doing the discrimination based on contextual typing info. This is what we have done for tuples vs arrays. Namely, an array literal contextually typed by a tuple type has a tuple type. In a similar way, a string literal contextually typed by a string literal type has a string literal type (the thing you are calling a set type). This is in order to maintain backward compatibility.

JsonFreeman commented 9 years ago

I will add that unless I misunderstood your proposal, the difficulty is that it does not maintain the same overload selection rules when you pass a string literal as an argument to a call.

DanielRosenwasser commented 9 years ago

Yes, while it doesn't seem like typing const-declared strings as string literal types would be a problem, it unfortunately is:

function foo() {
    const a = "hello";
    return a;
}

var x = foo();
x = "world";

Here, a would be typed as "hello", so foo's return type is now "hello". In turn, x is typed as "hello", but now x cannot be reassigned any other string.

JsonFreeman commented 9 years ago

@DanielRosenwasser With the contextual typing approach, you could still run into a similar thing. I'm modifying your example a bit to add a type annotation:

function foo(): "hello" {
    return "hello";
}

var x = foo(); // Inferred as "hello"
x = "world"; // Still an error

If you wanted x to be of type string, you would have to add a widening rule that all string literal types widen to string. But I'm not sure we would want that.

DanielRosenwasser commented 9 years ago

With the contextual typing approach, you could still run into a similar thing.

But at that point you've opted into an explicit string literal type, whereas having const declarations implicitly do so is a breaking change, so I'm not seeing the issue in that case.

JsonFreeman commented 9 years ago

Yes, I agree. Just wanted to make sure.

DanielRosenwasser commented 9 years ago

I don't think that would be intuitive for users (it is inconsistent between types of declarations), but I can see some merits to that approach.

JsonFreeman commented 9 years ago

Neither the contextual type approach nor the widening approach results in different treatment for const declarations versus variable declarations. Unless somebody voices a clear advantage for the widening approach, I favor contextual typing.

JsonFreeman commented 9 years ago

One interesting question is whether a string literal type should be nullable (allow null / undefined as a value). Right now, our type system has types for null and undefined, but these types are assignable to all types. Should string literals be an exception because they denote one particular value? The motivation is this:

interface StringWrapper {
     kind: "string";
     value: string;
}
interface ArrayWrapper {
     kind: "array";
     value: string[]
}
var v: StringWrapper | ArrayWrapper = { kind: undefined, value: ["hello"] }; // Suppose this assignment is allowed
if (v.kind === "array") {
    // Clearly, v has type ArrayWrapper here
}
else {
    // Process of elimination suggests that v is of type StringWrapper, but that is not true.
    v.value; // string[]
}

There are 3 options:

  1. Null and undefined are not assignable / subtype of a string literal type or a union of string literal types. This would be intuitive, but somewhat against the grain in terms of the existing rules in the type system.
  2. Null and undefined are assignable, and we don't allow a string literal check to narrow a type in the false branch of a type guard construct. This is safe, but we give up some usability.
  3. Null and undefined are assignable, and string literal checks can narrow in both branches. This means string literal type guards are kind of fuzzy, and can be misleading at times, but they are usable, and we get to keep our type system fully nullable.

Thoughts??

nycdotnet commented 9 years ago

Interesting dilemma! I've been thinking about this for an hour or so, and have typed up three different responses to how there was an obvious choice and the rest were evil.

I must say that the most vexing part of this code sample is // Suppose this assignment is allowed.

My selections in order of decreasing preference:

I don't think choice 1 fits with today's TypeScript.

yortus commented 9 years ago

I think option 1 is correct and preferrable for the kind of type-level reasoning that singleton types are able to bring to the language.

I think of a singleton type as a subset of a broader type where the subset is a single value from the broader set, ie a singleton set. null and undefined are always members of the broader set, but a singleton type is a subset and can therefore exclude these two values.

If singleton types implicitly always also include null and undefined as allowed values, they are not really singletons at all, they are 'tripletons' or something, with the weaker powers of type-level reasoning that go with that.

eg var abc: 'abc' is stating explicitly that abc can only have the value 'abc'. null and undefined are explicitly not members of this type's value set.

Is there some scenario using singleton types where assigning null and/or undefined would be useful? Or is this perhaps more an issue with the way the compiler currently works? (Or both?)

nycdotnet commented 9 years ago

@yortus A very straightforward example that I can think of is setting the value of a variable programmatically, e.g.:

function numberRangeName(num: number)
    var x: "positive"|"negative"|"zero";  // x is undefined here.
    if (num > 0) {
        x = "positive";
    }
    ///etc...
    return x;
}

Of course you could always have a "NotSure" in there, but then that's basically the same thing as allowing undefined, just more explicit. I know you could write this simple example slightly differently to eliminate the temporary variable, but more complex examples might require it.

JsonFreeman commented 9 years ago

@jbondc, you cannot denote the null type and undefined types explicitly today. I'm not sure there is value in adding those words in the type syntax, rather than some sort of nullability marker.

To @yortus's comment, I was going to say that null and undefined are not really in the spirit of singleton types, and having the types be nullable is just a type system limitation. But I am actually convinced by @nycdotnet's example, where the value is set after the declaration point. If we made these types non-nullable, then you would have to modify the code in that example to take advantage of var scoping:

function numberRangeName(num: number)
    if (num > 0) {
        var x: "positive"|"negative"|"zero" = "positive";
    }
    ///etc...
    return x;
}

But while this is a pattern we support, it's not a pattern we want to encourage.

yortus commented 9 years ago

var x: "positive"|"negative"|"zero"; // x is undefined here.

That's just an uninitialised variable. The compiler can already flag an error for this - eg:

const a; // Error: 'const' declarations must be initialized

Its also trivially easy to avoid (with no need to exploit var scoping)

function numberRangeName(num: number)
    var x: "positive"|"negative"|"zero" = "zero";
    if (num > 0) {
        x = "positive";
    }
    ///etc...
    return x;
}

I guess there's a trade-off for the user of singletons: either they must pay the cost of initialising singleton variables to a valid value, or they must pay the cost of having to have extra type guards and checks everywhere the values are used.

I guess I was wondering if there is a useful example of allowing null/undefined in a singleton? ie where it helps express or solve a problem at hand.

JsonFreeman commented 9 years ago

Talked with @mhegazy. We think that this issue really doesn't have much to do with string literal types. Instead it has more to do with type guards that involve property accesses. To this end, our inclination is that if you have a type guard based on a property access, we will only narrow the type in the true branch. The false branch could trick us into doing a faulty process of elimination.

yortus commented 9 years ago

@JsonFreeman can you clarify what this means for whether or not the type system would consider null/undefined as valid values for singleton types?

JsonFreeman commented 9 years ago

Sure, it means that singleton types are just like any other type, in that they implicitly allow null and undefined. This minimizes friction with the rest of the type system.

I do see the merit in making singleton types not allow null or undefined. However, doing that would mean that we need to provide a way to denote a nullable singleton type. We have thought about nullability in general from time to time, but so far have not come to any promising conclusions about how it would be accomplished. For better or worse, our type system today is nullable at its very core.

yortus commented 9 years ago

Got it. Still curious to see an example illustrating why someone might want to constrain a type to a singleton value and also want it to be nullable. Bearing in mind this is all purely about compile time type checking since singleton types obviously have no runtime support.