microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.43k 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 */
}
yortus commented 9 years ago

Clarifying my last comment... I would like to know the justification for this statement:

we need to provide a way to denote a nullable singleton type

What need does a nullable singleton satisfy?

yortus commented 9 years ago

Also isn't undefined itself (as in the value of the expression void 0) an instance of both: a) a singleton type b) a non-nullable type

So its not like they are unprecedented.

alainfrisch commented 9 years ago

Instead it has more to do with type guards that involve property accesses. ... we will only narrow the type in the true branch. The false branch could trick us into doing a faulty process of elimination

I don't follow the reasoning.

First, for a variable whose type is an union of singletons and a guard testing one of the values (which does not involve any property access): the fact that the value can be null or undefined should not change the fact than in the false branch, there is one less possibility. Now if you test all possibilities (with a cascade of if-then-else or a switch), you're left with an empty union, which could still denote either null or undefined (if you decide so). This can be tested with further branches, or asserted by calling a function taking precisely this empty type (a soft version of "exhaustivity modulo non-nullness" checking).

Now, if the guard in on a property access (with an initial type being typically a union of objects with a discriminator field), the same approach would still work: when testing if the property has a specific value, the false branch should have the knowledge that the corresponding case(s) in the union are no longer possible. I don't see why allowing null/undefined would affect that.

JsonFreeman commented 9 years ago

@yortus The reason it is valuable to denote a nullable singleton type is the example @nycdotnet gave: https://github.com/Microsoft/TypeScript/issues/1003#issuecomment-112094477. Like you said, in order to get around this, you have to have a sentinel value ("zero" in the example you gave), which seems kind of unnatural.

Your point about undefined being a non-nullable singleton type: That is true, but the type system treats undefined specially in two important ways, which make it not function as a precedent for non-nullable types:

  1. It is a bottom type.
  2. It gets widened when it is assigned to something.
JsonFreeman commented 9 years ago

@jbondc Your suggestion to fold in constant propagation seems to mix too many features together, and it seems to have much wider scope than the feature calls for.

JsonFreeman commented 9 years ago

The tagged union usage is the most similar thing to variant types that Typescript can have. The problem is that there are no first class "labels" in Javascript, so we use type guards instead. And then to make a label at compile time, there is a choice between using const entities (what you suggested), or types that represent constant values (what this proposal was about).

teppeis commented 9 years ago

Flowtype implemented similar feature: http://flowtype.org/blog/2015/07/03/Disjoint-Unions.html

yortus commented 9 years ago

@teppeis looks nice, and demonstrates the simplicity of coding with truly disjoint unions. According to the discussion above it appears TypeScript may not allow type narrowing in the else case due to null and undefined being implicitly allowed values of every singleton type.

Nevor commented 9 years ago

@teppeis , @yortus Actually, it's the same feature, the examples are very familiar since it's actually this issue that was cross posted on flow's github (https://github.com/facebook/flow/issues/135) completing an other proposal (https://github.com/facebook/flow/issues/20).

Concerning the type narrowing, the softer handling of null and undefined of TypeScript do not prevent us from doing it in the else case. Actually all examples given in this blog post should work in my proposal prototype.

yortus commented 9 years ago

@Nevor regarding type narrowing that's great if it can be done in the presence of null/undefined. Are you talking along the lines of option 3 in this comment?

Nevor commented 9 years ago

Yep, in the case of Typescript, we are already in a world where everything can be null/undefined, I don't see the need to treat this case differently. Moreover, it's very easy to transform an "unsafe" if-then-else into a safe switch or if-then-else-if-then.

JsonFreeman commented 9 years ago

Yes, there are certainly no technical limitations to narrowing a nullable in the else clause, I was just wondering whether people wanted this looseness. Sounds like the answer is yes.

Btw, @RyanCavanaugh made a good point that by this philosophy, we should also narrow in the else clause of an instanceOf guard.

dead-claudia commented 9 years ago

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

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.

knuton commented 8 years ago

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

mhegazy commented 8 years ago

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

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

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