-
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
Assertions in control flow analysis #32695
Conversation
Really like this! Curious if instead of |
No, because the two are not equivalent. |
@typescript-bot perf test this |
Heya @ahejlsberg, I've started to run the perf test suite on this PR at fe70a62. You can monitor the build here. It should now contribute to this PR's status checks. Update: The results are in! |
@ahejlsberg Just curios, the official position when multiple such issues were raised was that adding all potential call expressions will grow the CF graph to much and thus it was not really feasible to add this feature. My question is what changed ? Was the reasoning flawed, other performance improvements now make this less of a perf concern, or this is still experimental and could still be axed if performance does meet expectations ? |
Ah! So
|
It's not just that.
|
@ahejlsberg Here they are:Comparison Report - master..32695
System
Hosts
Scenarios
|
@dragomirtitian What changed? First realizing that the CFA node to AST node ratio is pretty low (about 10% for the compiler itself, for example), and further that we can restrict ourselves to only including top-level expression statement call nodes in the CFA graph. Again, using the compiler itself as an example, this PR only increases the number of CFA nodes by 7.5%. So, overall we're talking less than 1% of additional memory overhead. And execution time overhead is very low when CFA call nodes turn out to not be assertions. The perf test bot numbers appear to confirm this. Less that 0.1% memory overhead and zero execution time overhead. If anything, I would actually have expected more impact. I guess it's sometimes good to question conventional wisdom. Even when it's your own! |
This I understand :-). What I am doing a poor job of expressing was that in my mind the declare const x: string | number;
const isString = typeof x === 'string'; // isString: (false & x is number) | (true & x is string);
if (isString) {
x; // x: string;
} So I retract all I have said, and have fully joined the |
Love this as it would make input validation (even against something like a JSON schema) a lot less clunky! What makes me think though: Have you thought about expressing this with return types instead? assertString<T>(value: T): T extends string ? void : never Assertion functions are really just functions that throw errors in certain cases. A function returning This was also suggested and upvoted in the issue: #8655 (comment) The only thing a conditional assert(typeof x === 'string') but I think that is actually a good thing. People should use specialized assertion functions, because they would throw an assertion error like
instead of
which is not helpful. Plain It also seems like expect(someValue).toBeString()
function expect<T>(value: T): Matcher<T>
interface Matcher<T> {
toBeString(): asserts ??? is string; // can't reference value here anymore
} while that would work great with function expect<T>(value: T): Matcher<T>
interface Matcher<T> {
toBeString(): T extends string ? void : never;
} |
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.
technically it's a breaking change, because the following code no longer parses without error (but what are the odds such code really exists?)
declare function f(asserts: unknown): asserts is string;
@@ -1276,6 +1284,22 @@ namespace ts { | |||
activeLabels!.pop(); | |||
} | |||
|
|||
function isDottedName(node: Expression): boolean { |
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.
what's the difference to isEntityNameExpression
?
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.
Good catch! No difference, will change to use the existing function.
src/compiler/binder.ts
Outdated
@@ -1276,6 +1284,22 @@ namespace ts { | |||
activeLabels!.pop(); | |||
} | |||
|
|||
function isDottedName(node: Expression): boolean { | |||
return node.kind === SyntaxKind.Identifier || node.kind === SyntaxKind.PropertyAccessExpression && isDottedName((<PropertyAccessExpression>node).expression); |
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.
Is there a reason not to include this
and super
in property access expressions?
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.
I think that would be okay, but I'll have to convince myself it can't trigger circularities in control flow analysis.
src/compiler/parser.ts
Outdated
node.assertsModifier = parseExpectedToken(SyntaxKind.AssertsKeyword); | ||
node.parameterName = parseIdentifier(); | ||
if (parseOptional(SyntaxKind.IsKeyword)) { | ||
node.type = parseType(); |
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.
This makes this type of object polymorphic. Could you instead always assign the property and use undefined
if there is no 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.
Yup
@@ -3225,6 +3228,16 @@ namespace ts { | |||
} | |||
} | |||
|
|||
function parseAssertsTypePredicate(): TypeNode { | |||
const node = <TypePredicateNode>createNode(SyntaxKind.TypePredicate); | |||
node.assertsModifier = parseExpectedToken(SyntaxKind.AssertsKeyword); |
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.
adding this property here and not assigning it in parseTypeOrTypePredicate
where regular TypePredicate
nodes are constructed, create yet another hidden class that hinders optimization at runtime.
Either assign it last in this function or (even better) assign it in both functions in the same order
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.
Agreed
@@ -3225,6 +3228,16 @@ namespace ts { | |||
} | |||
} | |||
|
|||
function parseAssertsTypePredicate(): TypeNode { | |||
const node = <TypePredicateNode>createNode(SyntaxKind.TypePredicate); | |||
node.assertsModifier = parseExpectedToken(SyntaxKind.AssertsKeyword); |
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.
Is there a possibility that there will be more modifiers in the future? If so, would it make sense to put this into Node#modifiers
?
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.
It's possible, but for now I'm going to keep it the way it is.
src/compiler/factory.ts
Outdated
@@ -667,17 +667,18 @@ namespace ts { | |||
return <KeywordTypeNode>createSynthesizedNode(kind); | |||
} | |||
|
|||
export function createTypePredicateNode(parameterName: Identifier | ThisTypeNode | string, type: TypeNode) { | |||
export function createTypePredicateNode(assertsModifier: AssertsToken | undefined, parameterName: Identifier | ThisTypeNode | string, type: TypeNode | 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.
this is a breaking API change.
typically there is a new overload added to maintain backwards compatibility. the old signature can be marked as deprecated right away and could be removed later.
src/compiler/checker.ts
Outdated
@@ -16845,23 +16820,62 @@ namespace ts { | |||
return isLengthPushOrUnshift || isElementAssignment; | |||
} | |||
|
|||
function maybeTypePredicateCall(node: CallExpression) { | |||
function isDeclarationWithExplicitTypeAnnotation(declaration: Declaration | 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.
should this handle JSDoc as well?
It looks as though this can be used to track mutations, e.g.: class Foo {
constructor(public bar: boolean) {}
setBar<T extends boolean>(newBar: T): asserts this is Foo & { bar: T } {
this.bar = newBar;
}
}
const Foo = new Foo(false);
// foo is Foo
foo.setBar(true);
// foo is Foo & { bar: true } Or type Foo = { bar: boolean };
function setBar<T extends boolean>(foo: Foo, newBar: T): asserts foo is Foo & { bar: T } {
foo.bar = newBar;
}
const foo: Foo = { bar: false };
// foo is Foo
setBar(foo, true);
// foo is Foo & { bar: true } Is this correct? |
Another advantage to using the |
Really nice! Maybe we could use “asserts false” to represent a function that does not return? (Throws exception) This could help a bunch of case like assertNever, or unimplemented? Or maybe just “assert x is never” works? |
@ahejlsberg I have a couple of questions:
class Socket {
public async open() asserts this is CloseableSocket {
console.log("Opening...")
}
public async close() asserts this is OpenableSocket {
console.log("Closing...")
}
}
interface CloseableSocket{
close() asserts this is OpenableSocket;
}
interface OpenableSocket{
open() asserts this is CloseableSocket;
} Now it would be impossible to call open on the already opened socket and close the already closed socket. This would be really cool to see! |
How to write |
@treybrisbane Your second example works, but your first does not because @krzkaczor Pre-emptive apology for the pedantry, sorry. What you implement there is known as type-state. Linear (or affine) types are required to soundly implement type-state, but that code does not actually guarantee there is only one reference to a given object. That still looks like an interesting use of this PR though, and if you assume that the user is careful with their aliasing you might be able to add a lot of type-safety. The syntax:
also relies on new concepts, specifically having an expression (identifier) appearing in the check-type of a conditional type. On the surface I think it looks familiar to existing ideas, but there may be a non-trivial amount of new concepts required to implement and explain that feature thoroughly. I think if you want meaningful assertion messages (which is definitely desirable), it could be written like: function assertString(value: unknown): asserts value is string {
if (typeof value !== "string") {
throw "Expected 'string', got ${typeof value}";
}
} |
@jack-williams sorry, updated my comment, what I meant was: assertString<T>(value: T): T extends string ? void : never which does not require any new concepts. In fact, I would argue, it is almost a bit unexpected that this does not work already, because the semantics of |
This is needed to correctly move We also use |
@felixfbecker The difference between the two forms assertString(value: unknown): asserts value extends string; assertString<T>(value: T): T extends string ? void : never is that that we cannot necessarily make conclusions about an argument passed for |
@typescript-bot perf test this again to observe effects of including |
With this PR we reflect the effects of calls to
assert(...)
functions and never-returning functions in control flow analysis. We also improve analysis of the effects of exhaustive switch statements, and report unreachable code errors for statements that follow calls to never-returning functions or exhaustive switch statements that return or throw in all cases.The PR introduces a new
asserts
modifier that can be used in type predicates:An
asserts
return type predicate indicates that the function returns only when the assertion holds and otherwise throws an exception. Specifically, theassert x
form indicates that the function returns only whenx
is truthy, and theassert x is T
form indicates that the function returns only whenx
is of typeT
. Anasserts
return type predicate implies that the returned value is of typevoid
, and there is no provision for returning values of other types.The effects of calls to functions with
asserts
type predicates are reflected in control flow analysis. For example:From a control flow analysis perspective, a call to a function with an
asserts x
return type is equivalent to anif
statement that throws whenx
is falsy. For example, the control flow off1
above is analyzed equivalently toSimilarly, a call to a function with an
asserts x is T
return type is equivalent to anif
statement that throws when a call to a function with anx is T
return type returns false. In other words, giventhe control flow of
f2
above is analyzed equivalently toEffectively,
assertIsArrayOfStrings(x)
is just shorthand forassert(isArrayOfStrings(x))
.In addition to support for
asserts
, we now reflect effects of calls to never-returning functions in control flow analysis.Note that
f4
is considered to not have an implicit return that contributesundefined
to the return value. Without the call tofail
an error would have been reported.A function call is analyzed as an assertion call or never-returning call when
asserts
return type or an explicitnever
return type annotation.An entity is considered to have an explicit type when it is declared as a function, method, class or namespace, or as a variable, parameter or property with an explicit type annotation. (This particular rule exists so that control flow analysis of potential assertion calls doesn't circularly trigger further analysis.)
EDIT: Updated to include effects of calls to never-returning functions.
Fixes #8655.
Fixes #11572.
Fixes #12668.
Fixes #13241.
Fixes #18362.
Fixes #20409.
Fixes #20823.
Fixes #22470.
Fixes #27909.
Fixes #27388.
Fixes #30000.