Repository navigation
Singleton types under the form of string literal types #1003
Description
Activity
- addedSuggestionAn idea for TypeScriptAn idea for TypeScript
on Oct 30, 2014 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.kindwould be"square" | "rectangle" | "circle"
Type guards could relate the common
kindproperty 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; }
Reacted by Harm Berntsen and Ruan MendesDanielRosenwasser commented
on Oct 30, 2014 MemberMore actions👍 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
on Oct 30, 2014 MemberMore actionsThis also gives us a very easy way to reason about the desirable
memberofoperator, 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'
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
Expressionwould no longer be a base type, but rather a union type of all possible expression types. And those expression types would no longer inherit akind: SyntaxKindproperty, they would all have to introduce it themselves (with a particular set of values).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 === exprwithidentof union type andexprof literal type,identis narrowed to retain only types that are assignable to{ prop : typeof expr }iftypeof expris smaller thattypeof 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 commented
on Oct 31, 2014 MemberMore actionsRoven 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 }
In this case ˋsˋ will be treated as
Really | ReallyReadyand 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
on Nov 3, 2014 MemberMore actionsRoven 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.
While working on
switch/caseguards, I have realized that my algorithm forif-then-elseguards 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/caseguards. 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-elseorswitch/caseguard :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
breakorreturnas last statement of a case clause, extending the code to visit all execution paths should not be difficult.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.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: stringcase 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
testfunction, 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 commented
on Dec 12, 2014 More actionsGiven:
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
on Dec 13, 2014 MemberMore actionsDaniel Earwicker (@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.
- Besides Roven Lamar Gabriel (@Nevor) 's fork, is there a more up to date codebase with a working implementation ( however buggy )?
- 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.
55 remaining items
I see that Daniel Rosenwasser (@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.
- addedFixedA PR has been merged for this issueA PR has been merged for this issue
on Dec 1, 2015 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; }
- addedDomain: Literal TypesUnit types including string literal types, numeric literal types, Boolean literals, null, undefinedUnit types including string literal types, numeric literal types, Boolean literals, null, undefined
on Apr 5, 2016 DanielRosenwasser commented
on Apr 5, 2016 MemberMore actionsElephant-Vessel we're working on it. See #6196, though I don't know if we'll see it in 2.0.
- locked and limited conversation to collaborators
on Jun 18, 2018
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 :