-
Notifications
You must be signed in to change notification settings - Fork 31
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
using
introduces unnecessary complexity
#244
Comments
Learning the rule " Beyond standardization of the name of the disposal method, let x = getFoo(), y = getBar();
defer x.cleanup();
defer y.cleanup(); and not notice that if |
Agreed. But learning about the behavior of syntax is a problem regardless of the language. My argument is that
Tying disposal to acquisition is a strong argument for |
And since the whole point of the feature is to make it easier to clean up resources correctly, I think it would be worth a little ceremony in order to avoid the possibility of exceptions occurring between acquiring a resource and and registering it for disposal. |
I've really only experienced
Maybe this is a bad take, but to me, a lot of the motivation behind |
This really hasn't been my experience. Even with resources I'm authoring, it's literally a single extra line - The only clunky cases I've run into are when using something which does not already have a I accept that you find it more clunky than
In this context, it means ensuring that the clean up logic runs in all cases, and at a predictable time. The way we'd normally do that is with It's true that sometimes you want something other than scope exit, and that this will not work for you in that case. Neither would |
I totally agree that, in isolation, Here's a good example that I've ran into more than a few times: needing to asynchronously await things at the end of a block after iteration.
const promises = []
defer await Promise.all(promises)
for (const x of arr) {
defer promises.push(x.dispose())
// use x
}
await using stack = new AsyncDisposableStack()
for (const x of arr) {
// I need to make sure `Symbol.asyncDispose` is implemented for `x` to use this :(
stack.use(x)
// use x
} Notice that I can't use
Also, my And look, I can see the value in other parts of the proposal ( const stack = new AsyncDisposableStack()
defer await stack.dispose() Is that really so bad?
Much of the disagreement here comes down to both the cost and value of
Yup.
The value of tying resource acquisition with disposal registration is overstated. const r = new Resource()
defer r[Symbol.dispose]() Sure, it's a little bit easier to remember to write
This feels incredibly idealistic. We should seriously reconsider adding features to a mature language based on hopes and dreams. I honestly hope I'm wrong and that |
Oh wait, this example is actually wrong: await using stack = new AsyncDisposableStack()
for (const x of arr) {
// I need to make sure `Symbol.asyncDispose` is implemented for `x` to use this :(
stack.use(x)
// use x
} Because it doesn't dispose each element until the very end. I honestly don't even know how to implement my desired behavior without resorting to await using stack = new AsyncDisposableStack()
const promises = []
stack.defer(() => Promise.all(promises))
for (const x of arr) {
using stack2 = new DisposableStack()
stack2.defer(() => promises.push(x.dispose()))
// use x
} This seems extremely convoluted. It took significantly longer to create this solution vs. |
This would not work the way you expect in a language like Go, where
First, you do not need to ensure await using promises = new AsyncDisposableStack();
for (const x of arr) {
promises.defer(() => x.dispose());
// use x
} which would collect each promise to dispose when Second, while it introduces some new concepts, the code is structurally the same and involves fewer steps. Rather than maintaining a Third, Go's
With both the const promises = [];
defer await Promise.all(promises);
for (const x of arr) {
await work(promises, x);
}
function work(promises, x) {
defer promises.push(x.dispose());
// use x
} With const promises = [];
await using stack = new AsyncDisposableStack();
stack.defer(() => Promise.all(promises));
for (const x of arr) {
using cleanup = new DisposableStack();
cleanup.defer(() => promises.push(x.dispose()));
// use x
} // x.dispose() is invoked serially but its result is not observed until `stack` is disposed. Yes, this is more complex, but the operation you are performing is also complex, and easy to get wrong. If all you need is const defer = op => ({ [Symbol.dispose]() { op(); } });
const asyncDefer = op => ({ async [Symbol.asyncDispose]() { await op(); } });
const promises = [];
await using _ = asyncDefer(() => Promise.all(promises));
for (const x of arr) {
using _ = defer(() => promises.push(x.dispose()));
// use x
} And unlike Go's
While I agree that it is simple, I disagree about its versatility. Anything you can do with
It requires far more repetition when composing the API as you must regularly repeat calls to Syntactically, defer (await foo.bar()).baz(); Which means you'd have to disallow
IMO,
{
using lck = mutex.lock();
...
}
I strongly disagree with this statement. This was one of the main motivations for this proposal from the start. I've seen cleanup registration issues in numerous codebases, and far too much inconsistency in cleanup APIs in both user code and in host API's like the DOM.
This is actually fairly common practice in languages like C# and Python, which are both prior art for this proposal. In addition, the
There are quite a few new features that are on the way that will make use of |
Simplicity naturally leads to composition. Yeah sure, it doesn't have a consistent API because there's literally nothing that needs consistency for it to work. Both
My
What is the point of
My
That's because
So far,
I would hope that the tools I'm using don't fall apart as the complexity of the problem grows.
Sure, it increases repetition in cases compared to
I'm aware of this problem, but really, I think it's a fine compromise to require
A similar problem already exists for parenthesized expression statements, requiring a semicolon before the statement in some cases. While this isn't good, I've never found it to be a massive problem.
Seems fine to me, "Are you missing void after defer?"
This is idealistic. What about Bun? Deno? The huge number of History doesn't paint such a nice picture. ESM is still a struggle, and that has way more incentives for adoption vs.
Yes it is convenient for that particular use-case. My argument isn't that
Why do we have to solve all 3 problems with one solution?
Consistency with other languages is good, but why are other languages not looked at? It feels like
And it's clunky because I have to now create a stack within the current scope. When I just wanted
The fact that Let me clarify the purpose of That has staying power. I just don't see the same for
I don't doubt that |
I've started a proposal for I have this implemented in my transpiler as well. I would not go out of my way to do all of this unless I thought |
using x = resourceA() to be equals to const x = resourceA()
defer x[Symbol.dispose](); seems like it would be a neat syntactic sugar offering more flexibility if need be. Similar as to sometimes Promise is useful when dealing with async constructs. await using x = resourceB() to be equal to const x = resource()
defer await x[Symbol.asyncDispose]() seems like it would entirely make sense from a educational POV to have both. |
I've come to the conclusion that
using
, as defined in this proposal, does not belong in the language.using
adds additional complexity to the language with no clear benefits over alternatives like Go/Zig'sdefer
.Capturing
This is the biggest flaw in the current proposal. The syntax is tied to symbols, yet the behavior is tied to scopes. So I can write
using r = new Resource()
which then may be referenced by something after the scope ends andr
is disposed.As far as I can tell, this has been acknowledged but not resolved. Why is the behavior of captured symbols not mentioned anywhere in the proposal? While it might be obvious to those who know the technical details of
using
, I believe this introduces a major "footgun" for the average developer.Here's a simple example to demonstrate this:
Attempting to use
y
in the closure results in an error!The behavior is the same with
esbuild
:esbuild example.ts --target=node22 | node
I do not believe this aligns with the spirit of JavaScript. Normal variables captured by closures don't just suddenly break, am I not using
y
within the closure? While I still have access toy
, the semantics ofusing
mean that it's effectively inoperable. The feature that aims to mitigate common footguns introduces a new one for closures, one of JavaScript's defining features.defer
with extra stepsIf
using
doesn't work with closures (as it clearly doesn't), you can describe the same behavior using an unconditionaldefer
statement that executes on scope exit:Now this is interesting, because the problem with my code is now much more obvious. It's because I'm disposing of the resource after returning the closure! The very name
using
promises a lot but doesn't deliver anything more thandefer
. So why haveusing
to begin with? We're adding complexity and hiding behavior from developers for, as far as I can tell, no benefit beyond standardization of which method to call for disposal and enabling instantiation of multiple disposable resources in a single statement.Rigidity and friction
using
imposes requirements that impede usage:Symbol.dispose
(orSymbol.asyncDispose
)using
becomes even more verbose than just havingdefer
from the start.using _ = x // dispose of x
in future codebasesusing
instead ofvar
/let
/const
)defer
has none of these requirements with the same functionality, while also offering far more immediate value to users because it "just works" with existing code. The current proposal adds unnecessary friction for the functionality of "execute this when the scope exits". Even if developers have a use-case whereusing
makes sense, they're less likely to "do the right thing" because of the added friction. Why are we getting in the way of developers?Suggestions
using
only makes sense if the disposal behavior is tied to the lifetime of the binding, not the scope in which the binding was declared. Currently,using
is just a more confusingdefer
.My current suggestions:
using
- The current proposal should be reworked so that the disposal behavior is tied to the lifetime of the resource binding itself, meaning the resource would only be disposed of when it’s no longer referenced, even across closures. This effectively becomes an ergonomic way to add finalizers to JS objects.defer
- An unconditionaldefer
statement that executes on scope exit has the same functionality as the current proposal without the additional complexity. You can even address async use-cases by treating theawait
indefer await
as a modifier todefer
rather than AwaitExpression. Build tools should be able to re-use existingusing
logic fordefer
because they're essentially the same thing. I've tested this myself.These two features would no longer overlap as each provide their own set of benefits and use-cases:
defer
for scope lifetimes,using
for binding lifetimes. This will keep the language relatively simple and approachable for developers while still providing power and flexibility.The text was updated successfully, but these errors were encountered: