Mutability expiration & `undeclare`

These are 2 proposals in 1, because I think (I may be wrong) they work better together. However, they can be split into separate proposals.

Mut "expiration"

This is about limiting the number of times a variable can be assigned, either statically (syntactic and lexical) or dynamically (at runtime). Examples:

//var can only be assigned twice (after declaration) at runtime
const 2 x = 0

//loop runs twice until error is thrown
//`x === 2` before the loop halts
while (true) x += 1

//suppose the previous loop hasn't crashed...

//var can only be assigned at most 2 "places" in the code
synconst 2 y = 0

//this never crashes
//the var is only assigned at 1 "place" multiple times
for (let i = 0; i < 256; i++)
    y += 1

//despite never running, it still counts as "syntactic assignment"
if (false) {y = 2}

y = 3 //syntax error

These are specially useful when a variable should be immutable but its value must be computed dynamically once or more times.

  • const 0 is identical to const
  • synconst 0 is an stricter version of const
  • const Infinity is a syntax error (because Infinity is not an integer)
  • synconst Infinity is identical to let
  • const -1 and synconst -1 are undefined behavior (it may be defined later)

Reverse declarations

Suppose we have the following:

//"..." means "do something, but don't declare vars"
//...
{
    let a = 0
    //...
    {
        let b = 1
        //...
        {
            let c = 2
            //...
        }
        //...
    }
    //...
}
//...

This "indentation penalty" discourages the good practice of local variables. What if we could do something like?...

//...
let a = 0
//...
let b = 1
//...
let c = 2
//...
undef c
//...
undef b
//...
undef a
//...

This is equivalent to the delete operator (and drop in Rust), except that undef only works on vars declared via let & const. Attempting to undef a variable declared with var or a property of globalThis throws an error (it may be at runtime or "compile"-time)

To discourage abuse of undef, it should have a longer name, like undeclare. Another mechanism to enforce maintainability of code (maybe it's the opposite?, I feel this is wrong), would be to have different keywords associated with the kind of declaration: unconst can only undeclare const, and unlet corresponds to let, mismatching results in an error.

A cool feature of undef is that it can emulate the mut-expiration feature:

let x = 0
//...
x = 1
//...

//re-declare x as a constant
const x = undef x
//undef is an operator that returns the value before deleting it

x = 1 //error

const y = 0
//...

//this is forbidden, because it would destroy the guarantee that `const` is immutable
let y = undef y //error

Re the second, I’ve never actually seen indentation perceived as a penalty nor discouraging good variable practices, and i think the penalty on understanding that would be caused by having variables go in and out of scope would be huge. Separately, at the recent plenary implementers argued (in reference to a TDZ for disposal) that having variables go out of scope would prevent optimizations and slow down the entire engine.

1 Like

On point 1:

Do you think you could expound on why it might be useful to state that you can only assign to a variable X amount of times before it becomes constant? For me, the value of having let vs const in the first place, is because it gives us as programmers an easy way to glance at the code and know what's going on (oh, this is constant, so I don't have to check if, in the middle of this long function body, if it ever gets re-assigned). If we make a "mutability expiration" mechanism that requires us to count the number of assignments that have happened, this seems like more mental overhead for a programmer to worry about, not less (oh, this is a mutable-expiring value, let me just count the number of times an assignment could happen to this variable to see where it becomes constant at. Let's see... 1... 2... 3... Is that 4? Did x++ count as assignment?)

With that said, I can see value if having a "mutable until the first assignment" type of declaration. There is precedence for this sort of thing in other languages, like Java's "final" would allow you to assign to it once if you haven't already done so. So, something like this could be nice in JavaScript.

const x; // No initial assignment, so a single assignment is allowed lower down.
// Accessing `x` before the initial assignment would result in an error.
if (...) {
  x = 2;
} else {
  x = 3;
}
// Now that `x` hold a value, it is immutable, and can't be assigned to anymore.

On point 2:

I've got to agree that the extra nesting does discourage me from using extra nested scopes a bit. I'm not sure manually undeclaring individual variables is a great alternative though. The noise of manually undeclaring a bunch of undeeded values is probably worse than just adding another layer of nesting.

One idea I've toyed around with, but I sort of doubt it would ever get anywhere, would be to add the ability to "attach" one or more declarations to the next statement. As soon as the target statement finishes executing, the declaration goes out of scope. So, something like this:

with const users = await fetchUsers();
with const groups = users.map(user => user.groups);
with const roles = rolesFromGroups(groups);
await doCalculation(users, roles);

In the above example, "users", "groups" and "roles" are all available when the await doCalculation(user, roles); line runs. As soon as that finishes executing, those variables will not be available anymore. I think something like this would provide a clean way take a long line of code and split it up into multiple lines, without having to pollute the current block with a lot of new variables. It's also very easy to see at a glance the lifespan of these variables (they're very short), without having to make nested blocks for things this simple.

When you attach a declaration to a statement that has a block, we could choose to make those declarations available for the duration of the block as well. e.g.

with const matches = /[a-z]*/.exec(myInput);
if (matches) {
  // matches is available here
}
// matches is not available here

While I think such an idea would be pretty cool, I sort-of expect the response to something like this, along with many of the points being discussed in this thread, would be to "use smaller functions", which is certainly a valid solution. The lifespan of a variable, or how long it's mutable or immutable matters much less if you're dealing with shorter functions.

1 Like

How? Why? I didn't know that. But it kind of makes sense, because of garbage collection. However, there may be special cases where explicitly droping a var may "relief" the GC because of parse-time reference-counting

Well, no. I can't think of a good example use case, yet.

A partial solution would be to provide an operator that returns the current "assignment-quota" of a variable. Something like mutquota or mutcount. But that only makes the code know the count directly, the programmer still has to either count manually or run a line in a REPL to know the value.

In Rust, those are usually known as "lazy static" vars, but the concept of lazy constant is more generic and extensible, since not all constants have to be static.

I agree with that, I couldn't think of a better solution. However, I don't know if it would be "noisy". Another partial solution is to allow undef to be variadic, just like the var declarations:

let x, y, z
undef x, y, z

It's also (sometimes) good to explicitly mark the end of the lifetime of a var, rather than letting the scope get rid of it implicitly. Although I'm an advocate for conciseness whenever it's needed.

That (plus the example you provided) reminds me of Python and Lambda Calculus. In Py (as most people know) with open opens a file in a specific scope, and automatically closes the file when the code exits the scope (there's a tc39 proposal for resource management that works like this, but logically didn't use with because it's already reserved). In LC, values can be bound to "vars", but since LC is a purely functional lang, it cannot have state, so these "vars" can only be used within the function in which they were defined.

That kind of temporary var use can be emulated in JS like this:

const x = (() => {
    const HALF_PI = Math.PI / 2
    return Math.E + HALF_PI
})();

An IIFE, but with arrow fn (an "old" fn can also be used, if this is a concern)

Or, with the do block proposal when that comes out :).

Isn't this whole concept burgeoning dangerously close to the dreaded manual memory management? Quite frankly, unless ES will one day have the ability to dictate to the GC that it wants something reclaimed immediately, I just can't see the point of doing this. We have the irritating # syntax instead of the much clearer private for ergonomic reasons. So why would we then shun { let ... } in favor of let ...; undef ...;? That's just ridiculously inconsistent IMHO.

1 Like

Yes!

Also yes. I'm afraid it is. But only in the case of undef, "mutability-quota" is a different thing. I know JS != Rust, but in Rust, mut can be re-declared (shadowing) as immutable and viceversa. JS is already too dynamic, so I suggest being able to "lock" mutables as read-only, but never the other way around

I had this thought a long time ago, but never went anywhere with it. It occurs to me that if it were possible to treat the function scope as an object, it would be possible to do exactly that ("lock" mutable function variables). However, I get the distinct impression from other conversations that someone would think doing so would present some kind of security risk.

1 Like

How so? Is it because of prototype pollution?

I don't think object prototypes have any part in this. It might be a security risk simply because a ridiculously obvious mistake might lead to closures being corrupted by code external to the closure. However, making that kind of mistake seems too unlikely to me.

Thank you for clarifying. I'm not sure if the mistake you mentioned would be in a JS engine implementation, or a userland bug (maybe both?).

I suppose it's the userland side, since having objects that reflect local-scopes (similarly to globalThis, which reflects the global one) allows exposing any local-scopes to arbitrary code, only if the inner-scope passes the inner-object to the external-scope (or viceversa: if a scope passes the object to an inner one)

Now I understand why it may increase the likelihood of vulnerabilities