-
Notifications
You must be signed in to change notification settings - Fork 12.6k
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
Comments
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 "square" | "rectangle" | "circle" Type guards could relate the common 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;
} |
👍 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. |
This also gives us a very easy way to reason about the desirable 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' |
@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 |
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 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 |
@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
} |
In this case ˋsˋ will be treated as 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
} |
@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. |
While working on Switch/Case guardsThat being said, I have been able extend my prototype with 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 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 Further more, if we declare the accidentally allowed 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 limitationThe 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 |
@Nevor Can you use these new types with function overloading? e.g.
Addition/Suggestion: |
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 Concerning the |
Given:
It seems odd that in the object literal I assign to myFactory, having imposed the type CanaryFactory on it, I have to explicitly repeat |
@danielearwicker one of our aims is to avoid adding in runtime type information when it's not explicitly requested by the user. |
This thread is simply amazing.
|
@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:
If I leave out the assignment BTW if I just assign without declaring the type:
Then:
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. |
@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"
} |
@danielearwicker about your question on incompatible types, this is by design. The type inference for your field 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. |
@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 @Nevor - Thanks. That makes sense for ordinary variables and new properties introduced in a class. But what about a class property |
👍 |
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. |
Would this work with my proposal, particularly with the 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;
}
} |
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. |
I see that @DanielRosenwasser is now working on this. Any way we can help with community contributions? |
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. |
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;
} |
@Elephant-Vessel we're working on it. See #6196, though I don't know if we'll see it in 2.0. |
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
Typing specifications
Pitfalls and remaining work
Backward compatibility
This prototype is backward compatible (accepts programs that were accepted before) but in one case :
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 :
The text was updated successfully, but these errors were encountered: