-
Notifications
You must be signed in to change notification settings - Fork 12.7k
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
Support using
and await using
declarations
#54505
Conversation
f500987
to
587a1a4
Compare
} | ||
|
||
// `typeNode` is not merged as it only applies to comment emit for a variable declaration. | ||
// TODO: `typeNode` should overwrite the destination |
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've left this TODO since changing this is out of scope for this PR.
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.
Needs declaration emit tests, since these can be pulled into declaration emit via typeof
types nodes, eg
await using d1 = { async [Symbol.asyncDispose]() {} };
export type ExprType = typeof d1;
I'm pretty sure we'll need to transform them to normal non-using variable declarations, since there's no disposal stuff as far as the types care.
dispose = value[Symbol.dispose]; | ||
} | ||
if (typeof dispose !== "function") throw new TypeError("Object not disposable."); | ||
env.stack.push({ value: value, dispose: dispose, async: async }); |
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.
Unrelated: at what point will it be OK for us to use es6 features like object shorthands in our esnext downlevel helpers?
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 our helpers were an AST instead of a string, then we could arguably downlevel them on demand. Unfortunately, that wouldn't work for tslib
. Since we aren't downleveling the helpers, I'd say we can use new syntax only if we retire --target es5
and --target es3
.
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 left some comments/questions of some considerations that came up while implementing this in Babel.
} | ||
if (dispose === void 0) { | ||
if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined."); | ||
dispose = value[Symbol.dispose]; |
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.
In Babel we use Symbol.for("Symbol.dispose")
and Symbol.for("Symbol.asyncDispose")
as fallbacks, so that users can use polyfills that do not modify the global scope. This is very useful for libraries, so that they can do something
class MyResource {
[Symbol.dispose || Symbol.for("Symbol.dispose")]() {}
}
and they will be usable both natively and in older environments.
It might be great to align on this.
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.
TypeScript helpers generally don't polyfill/shim Symbols in this way. We usually depend on the developer to introduce any necessary global shim instead. I'm curious if @DanielRosenwasser has any thoughts on this approach, however.
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.
We discussed this in design meeting and don't believe TypeScript should support Symbol.for()
as a fallback. For one, Symbol.for("Symbol.dispose")
is only marginally an improvement over just using a string like "@@dispose"
, and we don't treat the result of Symbol.for()
as a meaningfully unique symbol, which is a requirement for us to properly type check it as a property name. As a result, a property name of [Symbol.dispose || Symbol.for("Symbol.dispose")]
produces a compilation error, so you wouldn't be able to produce that code without turning off error checking. Even if we did allow Symbol.for()
to produce a meaningfully unique symbol that we can type check, we would essentially have to bifurcate Disposable
to support each method name, and deal with the complexity of that only working in older compilation targets, since the Symbol.for()
fallback wouldn't be supported natively in --target esnext
.
Babel supporting for Symbol.for()
is actually a bit of a hazard for TypeScript. If someone were to publish a package that leveraged Babel's support for a Symbol.for()
fallback, they might in turn indicate that their objects are "disposable" in their documentation. That could lead to someone else putting together types for that package on DefinitelyTyped that indicate a class has a Symbol.dispose
method when it does not. This in turn could lead to errors during type check if the package's consumer isn't running with --lib esnext.disposable
, which could then result in them changing their --lib
to make the error go away without ensuring that their runtime environment actually supports a global Symbol.dispose
.
While I think it is commendable to support users' efforts to avoid global scope modifications, I don't think this is an approach TypeScript can take here. I'd even go so far as to recommend that Babel not provide such support, but given that Babel's support for Symbol.for("Symbol.dispose")
only has an exteremly remote possibility of causing problems for TypeScript projects, I'll leave that up to you.
I'll also note that, since the native DisposableStack
has a built-in mechanism to adopt non-disposable resources via stack.adopt(value, v => v.close())
and stack.defer(() => { value.close(); })
, a workaround like Symbol.for()
may not even be warranted.
src/compiler/factory/emitHelpers.ts
Outdated
env.hasError = true; | ||
} | ||
function next() { | ||
while (env.stack.length) { |
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 could copy your idea to optimize away the next()
tail recursion that Babel uses 👀
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.
Some initial comments before looking at the tests.
} | ||
declare var SuppressedError: SuppressedErrorConstructor; | ||
|
||
interface DisposableStack { |
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.
To check my understanding, is this a user-visible utility class to allow people to non-disposables to be disposed, and avoid disposal at the end of the block if they choose?
Is use
basically equivalent to using
, except that it also makes the disposable managed by the stack?
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, use
is the imperative equivalent of using
. One way to conceptualize this is that
{
using x = getX();
using y = getY();
doSomething();
}
is roughly equivalent to
const stack = new DisposableStack();
try {
const x = stack.use(getX());
const y = stack.use(getY());
doSomething();
}
finally {
stack[Symbol.dispose]();
}
(except that using
has additional semantics around handling error suppressions caused by disposal)
One of the RAII patterns I've used in C++ was to acquire a lock within a scope by constructing a variable. That variable wouldn't get referenced at all beyond its declaration because it's just used for automatic cleanup. One thing I found kind of "off" was that in the current implementation:
I think it probably makes sense to make the same exception we have for parameters, exempting them from this check as long as they're prefixed with an underscore. If we want, we can limit this to just |
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.
Just a couple of questions from me.
I think that makes sense, and I can look into that today. The proposal used to include a bindingless form a la |
One thing that I think we should seriousy consider is whether The thing I'm wary of is something like |
I'm not sure I like using the |
I've modified |
If there were a global const stack = new DisposableStack();
stack.defer(() => { ... }); I think we're more likely to see special-case disposables that use a more specific name, such as a built-in class SafeHandle<T> {
static #dispose = ({ unsafeHandle, cleanup }: { unsafeHandle: T, cleanup: (unsafeHandle: T) => void }) => {
cleanup(unsafeHandle);
};
static #registry = new FinalizationRegistry(SafeHandle.#dispose);
#unregisterToken = {};
#data: { unsafeHandle: T, cleanup: (unsafeHandle: T) => void } | undefined;
constructor(unsafeHandle: T, cleanup: (unsafeHandle: T) => void) {
this.#data = { unsafeHandle, cleanup };
SafeHandle.#registry.register(this, this.#data, this.#unregisterToken);
}
get unsafeHandle() {
if (!this.#data) throw new ReferenceError("Object is disposed");
return this.#data.unsafeHandle;
}
dispose() {
if (this.#data) {
SafeHandle.#registry.unregister(this.#unregisterToken);
const data = this.#data;
this.#data = undefined;
SafeHandle.#dispose(data);
}
}
[Symbol.dispose]() {
return this.dispose();
}
} |
@weswigham: I addressed the declaration emit request. Do you have any further feedback? |
This adds support for the
using
andawait using
declarations from the TC39 Explicit Resource Management proposal, which is currently at Stage 3.NOTE: This implementation is based on the combined specification text from tc39/proposal-explicit-resource-management#154, as per TC39 plenary consensus to merge the sync and async proposals together now that they both are at Stage 3.
Overview
A
using
declaration is a new block-scoped variable form that allows for the declaration of a disposable resource. When the variable is initialized with a value, that value's[Symbol.dispose]()
method is recorded and is then invoked when evaluation exits the containing block scope:An
await using
declaration is similar to ausing
declaration, but instead declares an asynchronously disposable resource. In this case, the value must have a[Symbol.asyncDispose]()
method that will beawait
ed at the end of the block:Disposable Resources
A disposable resource must conform to the
Disposable
interface:While an asynchronously disposable resource must conform to either the
Disposable
interface, or theAsyncDisposable
interface:using
DeclarationsA
using
declaration is a block-scoped declaration with an immutable binding, much likeconst
.As with
const
, ausing
declaration must have an initializer and multipleusing
declarations can be declared in a single statement:No Binding Patterns
However, unlike
const
, ausing
declaration may not be a binding pattern:Instead, it is better to perform destructuring in a secondary step:
NOTE: If option (b) seems less than ideal, that's because it may indicate a bad practice on the part of the resource producer (i.e.,
getResource()
), not the consumer, since there's no guarantee thatx
andy
have no dependencies with respect to disposal order.Allowed Values
When a
using
declaration is initialized, the runtime captures the value of the initializer (e.g., the resource) as well as its[Symbol.dispose]()
method for later invocation. If the resource does not have a[Symbol.dispose]()
method, and is neithernull
norundefined
, an error is thrown:As each
using
declaration is initialized, the resource's disposal operation is recorded in a stack, such that resources will be disposed in the reverse of the order in which they were declared:Where can a
using
be declared?A
using
declaration is legal anywhere aconst
declaration is legal, with the exception of the top level of a non-module Script when not otherwise enclosed in a Block:This is because a
const
declaration in a script is essentially global, and therefore has no scoped lifetime in which its disposal could meaningfully execute.Exception Handling
Resources are guaranteed to be disposed even if subsequent code in the block throws, as well as if exceptions are thrown during the disposal of other resources. This can result in a case where disposal could throw an exception that would otherwise suppress another exception being thrown:
To avoid losing the information associated with the suppressed error, the proposal introduced a new native
SuppressedError
exception. In the case of the above example,e
would beallowing you to walk the entire stack of error suppressesions.
await using
DeclarationsAn
await using
declaration is similar tousing
, except that it operates on asynchronously disposable resources. These are resources whose disposal may depend on an asynchronous operation, and thus should beawait
ed when the resource is disposed:Allowed Values
The resource supplied to an
await using
declaration must either have a[Symbol.asyncDispose]()
method or a[Symbol.dispose]()
method, or be eithernull
orundefined
, otherwise an error is thrown:Please note that while a
[Symbol.asyncDispose]()
method doesn't necessarily need to return aPromise
in JavaScript, for TypeScript code we've opted to make it an error if the return type is notPromise
-like to better surface potential typos or missingreturn
statements.Where can an
await using
be declared?Since this functionality depends on the ability to use
await
, anawait using
declaration may only appear in places where anawait
orfor await of
statement might be legal.Implicit
await
at end of BlockIt is important to note that any Block containing an
await using
statement will have an implicitawait
that occurs at the end of that block, as long as theawait using
statement is actually evaluated:This can have implications on code that follows the block, as it is not guaranteed to run in the same microtask as the last statement of the block.
for
Statementsusing
andawait using
declarations are allowed in the head of afor
statement:In this case, the resource (
res
) is not disposed until either iteration completes (i.e.,res.done
istrue
) or thefor
is exited early due toreturn
,throw
,break
, or a non-localcontinue
.for-in
Statementsusing
andawait using
declarations are not allowed in the head of afor-in
statement:for-of
Statementsusing
andawait using
declarations are allowed in the head of afor-of
statement:In a
for-of
statement, block-scoped bindings are initialized once per each iteration, and thus are disposed at the end of each iteration.for-await-of
StatementsMuch like
for-of
,using
andawait using
may be used in the head of afor-await-of
statement:It is important to note that there is a distinction between the above two statements. A
for-await-of
does not implicitly support asynchronously disposed resources when combined with a synchronoususing
, thus anAsyncIterable<AsyncDisposable>
will require bothawaits
infor await (await using ...
.switch
StatementsA
using
orawait using
declaration may appear in in the statement list of acase
ordefault
clause aswitch
statement. In this case, any resources that are tracked for disposal will be disposed when exiting the CaseBlock:Downlevel Emit
The
using
andawait using
statements are supported down-level as long as the following globals are available at runtime:Symbol.dispose
— To support theDisposable
protocol.Symbol.asyncDispose
— To support theAsyncDisposable
protocol.SuppressedError
— To support the error handling semantics ofusing
andawait using
.Promise
— To supportawait using
.A
using
declaration is transformed into atry-catch-finally
block as follows:The
env_
variable holds the stack of resources added by eachusing
statement, as well as any potential error thrown by any subsequent statements.The emit for an
await using
differs only slightly:For
await using
, we conditionallyawait
the result of the__disposeResources
call. The return value will always be aPromise
if at least oneawait using
declaration was evaluated, even if its initializer wasnull
orundefined
(see Implicitawait
at end of Block, above).Important Considerations
super()
The introduction of a
try-catch-finally
wrapper breaks certain expectations around the use ofsuper()
that we've had in a number of our existing transforms. We had a number of places where we expectedsuper()
to only be in the top-level statements of a constructor, and that broke when I started transforminginto
The approach I've taken in this PR isn't perfect as it is directly tied into the
try-catch-finally
wrapper produced byusing
. I have a longer-term solution I'm considering that will give us far more flexibility withsuper()
, but it requires significant rework ofsuper()
handling in thees2015
transform and should not hold up this feature.Modules and top-level
using
This also required similar changes to support top-level
using
in a module. Luckily, most of those changes were able to be isolated into the using declarations transform.Transformer Order
For a number of years now we have performed our legacy
--experimentalDecorators
transform (transforms/legacyDecorators.ts) immediately after thets
transform (transforms/ts.ts), and before the JSX (transforms/jsx.ts) and ESNext (transforms/esnext.ts) transforms. However, this had to change now that decorators are being standardized. As a result, the native decorators transform (transforms/esDecorators.ts) has been moved to run after the ESNext transform. This unfortunately required a bit of churn in both the native decorators transform and class fields transform (transforms/classFields.ts). The legacy decorators transform will still run immediately after thets
transform, since it is still essentially TypeScript-specific syntax.Future Work
There is still some open work to finish once this PR has merged, including:
tslib
for the new helpers.try-finally
containing a resource into ausing
.Fixes #52955