-
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
Discriminated union types #9163
Conversation
if (!hasNonEmptyDefault) { | ||
addAntecedent(postSwitchLabel, preSwitchCaseFlow); | ||
const hasDefault = forEach(node.caseBlock.clauses, c => c.kind === SyntaxKind.DefaultClause); | ||
// We mark a switch statement as possibly exhaustive if it has no default clause and if all |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is a switch/case non-exhaustive if it has a default? I feel like if it does, it is definitely exhaustive (because it accounts for all cases the user hasn't explicitly accounted for). Can you also document the answer in a comment here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If it has a default clause it is definitely exhaustive and not just possibly exhaustive. We already handle the definitely exhaustive case through normal control flow analysis (i.e. if all branches exit the post-switch label will have no antecedents).
# Conflicts: # src/compiler/binder.ts # src/compiler/checker.ts
what is the officially recommended way to do exhaustive checks? |
@Aleksey-Bykov To check for exhaustiveness you can add a function assertNever(x: never): never {
throw new Error("Unexpected object: " + x);
}
function area(s: Shape) {
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.width * s.height;
case "circle": return Math.PI * s.radius * s.radius;
default: return assertNever(s); // Error here if there are missing cases
}
} |
@Aleksey-Bykov Or you can just put the function assertNever(x: never): never {
throw new Error("Unexpected object: " + x);
}
function area(s: Shape) {
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.width * s.height;
case "circle": return Math.PI * s.radius * s.radius;
}
return assertNever(s); // Error here if there are missing cases
} |
wait a second function test1(s: Shape) {
if (s.kind === "square") { // <--- huh??
s; // Square
}
else {
s; // Rectangle | Circle
}
} does this mean this: #7447 ? |
@Aleksey-Bykov No, #7447 is a separate issue, but they're sort of related. The type guard |
return type; | ||
} | ||
const clauseTypes = switchTypes.slice(clauseStart, clauseEnd); | ||
const hasDefaultClause = clauseStart === clauseEnd || contains(clauseTypes, undefined); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So this is if it implicitly has a default, or clauseTypes
implicitly encodes an explicit default
through undefined
in place of a type?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes. We use clauseStart === clauseEnd
for the implicit default
that falls out the bottom of the switch
statement, and we use undefined
to mark explicit default
clauses.
@ahejlsberg What do you think of this idea: If class Foo { foo: any; }
class Bar { bar: any; }
interface HasFoo {
y: Foo;
a: number;
}
interface HasBar {
y: Bar;
b: string;
}
const x: HasFoo | HasBar = ...;
if (x.y instanceof Foo) {
x.a; // x: HasFoo
} else {
x.b; // x: HasBar
} |
@ivogabe That's an interesting idea. Other than the One concern is how this would affect performance. It seems that for every reference to |
@ahejlsberg I didn't have a specific use case in my mind, it came to my mind when I was working on my thesis. In theory it is a good idea to reuse the same logic for such cases, as it will give the most accurate results. I'm not sure how this will affect the performance of the compiler. I think that the impact is small given that this is limited to union types. I think that it would require an implementation to know that for sure. |
If the return type excludes function area(s: Shape):number {
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.width * s.height;
case "circle": return Math.PI * s.radius * s.radius;
}
// unreachable, since s has type never
} function area(s: Shape):number {
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.width * s.height;
}
// reachable, s has type Circle, function returns undefined, which is not compatible with number
} |
@jesseschalken make sure you compile with |
@jesseschalken There are a surprising number of interconnected issues in the reachability, control flow, and exhaustiveness topics. I will try to explain in the following. Our reachability analysis is based purely on the structure of your code, not on the higher level type analysis. For example, if you have code immediately following a function area(s: Shape): number {
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.width * s.height;
case "circle": return Math.PI * s.radius * s.radius;
}
// Unreachable?
} we can't conclude from the structure of the code that end point is unreachable. Indeed, someone might pass you a shape with an invalid function area(s: Shape): number {
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.width * s.height;
case "circle": return Math.PI * s.radius * s.radius;
}
fail("Invalid shape");
} But even if function area(s: Shape): number {
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.width * s.height;
case "circle": return Math.PI * s.radius * s.radius;
}
return fail("Invalid shape");
} we now know from the structure of the code that the end point is unreachable. Thus, there is no implicit return of To get exhaustiveness checks, we use a slight twist on the above and pass the guarded object as an argument to a function area(s: Shape): number {
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.width * s.height;
case "circle": return Math.PI * s.radius * s.radius;
}
return assertNever(s); // Error unless type of s is never
} Now, returning to the original example: function area(s: Shape): number {
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.width * s.height;
case "circle": return Math.PI * s.radius * s.radius;
}
} You'd think the above would be an error because the structure of the code does not preclude the implicit return of |
@bluemmc It's likely because TypeScript is not just a language that happens to support JavaScript as a compilation target (like some other languages do), but rather it is intended to be JavaScript with type safety added. In other words rather than asking "How do we support ADTs?" the question is "How do we add type safety to the ADTs people already have?" Nonetheless a dedicated ADT syntax would be nice. :) |
@bluemmc the Mozilla Parser API is a good (and pretty widely used) example of how string-typed discriminants are used in real-world JavaScript. TypeScript now adds a great deal of value for statically checking code that uses this kind of API. |
There a lots of both good and bad in the javascript language and common javascript practices. I do not believe that typescript should repeat past mistakes. Also remember, that the reason strings are often used in plain javascript is because there are no better way of doing things. In this case, there are. We should strive for great language design that solves problems without introducing new problems (or reintroducing old javascript problems). In addition, using types instead of strings allow more powerful future switch constructions (look at F# for examples). |
I'm not sure what mistakes are being repeated. We are modeling canonical JavaScript as it's used today. There are definitely ways we can expand on the current design that do not involve strings themselves, and even with what's been implemented so far, you can create constants and type aliases that refer to string literal types if you want to deliver clearer semantics over what a tag means. We could invent a completely new syntax that would encompass exactly what you're talking about (#9241), but that would diverge from ECMAScript in a significant way, and we are not keen on doing so. |
And TypeScript enables you to use them, although it has to live with the undeniable sadness of reality that there are significant amounts JavaScript patterns that cannot be modelled safely in TypeScript without this feature. |
Agreed. And that's why i really love TypeScript. Because it really tries to solve the problem of "How do i work with existing JavaScript and JavaScript libraries in a way that helps me find problems faster, and makes me more productive in general." TypeScript exists in a JavaScript world. One of the things that makes it great is that it embraces the code that is already out there and does not dictate that one needs to move away from it to get great experiences. That is absolutely solving a real problem, albeit maybe not exactly the one you want it to be solving :) The good thing is that, as mentioned above, you don't ever need to use this feature if you don't want to. if it's not how you write your own types, then that's fine. If you don't interface with existing libraries that work in this manner, then it won't ever affect you. However, I have a few apps i've written that talk to webservices that use precisely this pattern to model their results. Prior to this feature i would model the types i expected, but i had to write lots of code to check kinds and manually cast all over the place. It was ugly, verbose, and very redundant. This feature allows me to greatly simplify my code while still giving me the great error checking and productivity gains that i love about TS. |
@CyrusNajmabad I want to be able to use discriminated union types in a typesafe manner like F#. With such a design, there is nothing that stops people of expressly switching on strings instead using the string returned by xxxx.kind() @DanielRosenwasser Your are essentially argumentinf that discriminated union checks should not be typesafe because javascript is not. I thought the idea of typescript was to make things typesafe, maintainable and to enable tool-support. This proposal is none of these things because of this flaw. Thanks for the link to the much better alternative proposal though. |
Yes, it is type safe. That's the entire point of this issue. The The only thing that's wrong is it's a bit ugly and doesn't hide the type tag from you. But if you mistype one of the type tags you will still get an error, just as though you mistyped the name of a constructor for an ADT. |
@jesseschalken Unless this function gives a compiler error, then the proposal is NOT typesafe:
|
@bluemmc If I understand @ahejlsberg's comment correctly, it would. The switch is not exhaustive which means that the implied If the switch is not the last statement in the function (eg, the cases set a variable instead of returning), or all of the call sites in the same compilation unit happen to do something with the result that is permitted with If in the future the compiler considered code for which a local variable had type |
@bluemmc the way unions are (non-discriminated yet) in TypeScript is way better than sum types Haskell's or F#, because in TypeScript:
Let me explain. In Haskell you can't pass
Meaning they are not real types because you cannot declare a value of type Why is that? Because the internal mechanism of sum types requires these cases to be parts of the Now in TypeScript you can declare interface Some<a> { some: a; }
interface None { none: void; } And later you can:
So it gives you a greater degree freedom at the cost of... having to come up with your own way to destructure them into possible cases. Scared? Fear not, because thank to type guards and narrowing switch statements you are given all the tools you could possible need: interface Dunno { hm: void; }
const none: None = { none: void 0 }; // single case value declared alone, can't be done in Haskell
const some: Some<string> = { some: 'hey' }; // single case value declared alone, can't be done in Haskell
const dunno: Dunno = { hm: void 0 }; // single case value declared alone, can't be done in Haskell
let huh: Optional<a> = Math.random() > 0.5 ? some : none;
let meh: Uncertain<a> = Math.random() > 0.3 ? some : Math.random() > 0.5 ? none : dunno;
function isSome<a, b>(value: Some<a> | b) : value is Some<a> {
return 'some' in value; // one of many possible ways to discriminate Some out of an arbitrary union
}
meh = huh; // works! without having to transform Optional to Uncertain, can't be done in Haskell
if (isSome(meh)) {
// works! using one function to exclusively pattern match only Some case out of an arbitrary type that might have it
// can't be done in Haskell
alert(meh.some);
}
if (isSome(huh)) {
// works again! using the very same function to pattern match only Some case of a completely different type again
// can't be done in Haskell either
alert(huh.some);
} All in all, the union types in TypeScript together with various narrowing facilities give you ultimate freedom to design your ADT's the way you always wanted it. It's not a shorcoming as you think, it's a flipping blessing sent to us from the gods of programming above. Enjoy it. |
I am going with a simple |
@basarat such check saves you at compile time (assuming your code model is solid and consistent), but it doesn't save you at runtime when an unexpected compile-time-impossible case comes in. Then your check would silently suck it in like nothing happened. As opposed to a function that throws that would crash you fast and loud 👊 |
Having that throw in there feels a lot like the Also, I made a snippet in alm :) |
it's not mandatory it's just a question whether you trust your data or not say, you expect a shape of 3 cases then suddenly your colegues from a backend team added one more case without letting you know with your denial to throw you will know about it after days or weeks with a throw you will know much earlier your choice |
Agreed. I'd rather focus on codegen instead of adding a throw. We've done code gen on backend code to make sure that we get type defs that don't go out of sync. Without that even a simple matter of |
thinking of the advise in your book, you'd better be off suggesting to throw, because far not everyone is
|
That is only possible if you want nominal typing, but TypeScript's
Structured typing doesn't aid refactoring in the way nominal typing does.
Afaics, these structural sum types don't support compile-time extensibility of _existing_ classes (without editing their dependent code, e.g. a function returning a type of existing class), which afaics only typeclasses with unions could do.
Afaik this is because Haskell can't support first-class unions without breaking global type inference decidability. When you give up global type inference, then the declaring the union for a sum type can declared orthogonally to the data types which are members of the sum type. Although I think Haskell does support nominal (not anonymous, not first-class) unions (aka enum) in an extension. By first-class, we mean can interact with other higher-order typing constructs such as functions and subtyping. In general the Lamba cube is undecidable for global type inference away from its origin. |
All good reasons to hope Google's SoundScript becomes a compelling reality, eventually a standard, and that it supports the good parts from TypeScript. We'd still be able to use codegen with SoundScript as a target for extra features and experimentation. So TypeScript's raison d'être wouldn't necessarily end. |
This PR implements support for discriminated union types, inspired by suggestions in #186 and #1003. Specifically, we now support type guards that narrow union types based on tests of a discriminant property and furthermore extend that capability to
switch
statements. Some examples:A discriminant property type guard is an expression of the form
x.p == v
,x.p === v
,x.p != v
, orx.p !== v
, wherep
andv
are a property and an expression of a string literal type or a union of string literal types. The discriminant property type guard narrows the type ofx
to those constituent types ofx
that have a discriminant propertyp
with one of the possible values ofv
.Note that we currently only support discriminant properties of string literal types. We intend to later add support for boolean and numeric literal types.