Author: Nicholas C. Zakas Stage: -1 (not yet submitted)
This proposal seeks to allow const
declarations to be uninitialized and later written to just once.
You can browse the ecmarkup output or browse the source.
In certain situations, a constant binding's value may not be calculable at the time of declaration, leaving developers to use let
even though the binding's value will never change once set. Using let
on bindings that should be immutable can lead to errors.
This often happens when the value of a binding needs more than two conditions to be evaluated in order to determine the correct value to use. For example:
let value;
if (someCondition) {
value = 1;
} else if (someOtherCondition) {
value = 2;
} else {
value = 3;
}
This can be rewritten with a nested ternary, like this:
const value = someCondition
? 1
: otherCondition
? 2
: 3;
While some might argue that this solves the problem, it is debatable how readable this code is.
The problem becomes more apparent when you need to set multiple binding values using the same conditions, as in this example:
let value1, value2;
if (someCondition) {
value1 = 1;
value2 = 5;
} else if (someOtherCondition) {
value1 = 2;
value2 = 10;
} else {
value1 = 3;
value2 = 15;
}
In this case, you have no option to use nested ternaries without turning to destructuring and temporary object creation, such as:
const { value1, value2 } = someCondition
? { value1: 1, value2: 5 }
: someOtherCondition
? { value1: 2, value2: 10 }
: { value1: 3, value2: 15 };
This example further obfuscates the actual logic, making it more difficult to understand the purpose of the code all in the service of creating an immutable binding.
Some real world examples of this:
- Faker:
month()
- Faker:
fakeEval()
- Faker:
networkInterface()
In some cases, you may want to initialize a binding inside of a try-catch
but allow that binding to be accessed outside of the try-catch
. For example:
async function doSomething() {
let result;
try {
result = await someOperationThatMightFail();
} catch (error) {
// handle error and...
return;
}
doSomethingWith(result);
}
Here, the result
binding needs to be defined using let
even though it is only ever read after being set.
Some real world examples of this:
- Vite: Calculating cached meta data
- Vite:
init()
- node-pg-migrate: tsconfig calculation
I propose that const
declarations no longer require initialization. Specifically:
- An uninitialized
const
declaration is no longer a syntax error. - Attempting to read an uninitialized
const
binding before its value is returnsundefined
. - An uninitialized
const
binding may have its value set exactly once. - Attempting to set the value of an uninitialized
const
binding more than once causes the sameTypeError: Assignment to constant variable.
as assigning to any otherconst
binding. - Using
typeof
on an uninitializedconst
binding returns"undefined"
For example:
const value;
if (someCondition) {
value = 1;
} else if (someOtherCondition) {
value = 2;
} else {
value = 3;
}
value = 4; // TypeError: Assignment to constant variable.
const value2;
console.log(typeof value2); // "undefined"
console.log(value2); // undefined
Other languages supporting write-once immutable bindings typically have the following behavior:
- The binding cannot be read until a value is set (this is an error)
- The binding becomes immutable upon assignment of a value (any attempt to change the value is an error)
The following languages support creating immutable bindings in this way.
Rust allows the declaration of immutable bindings through the use of the let
keyword. The following example comes from the Rust Book:
fn main() {
// Declare a variable binding
let a_binding;
{
let x = 2;
// Initialize the binding
a_binding = x * x;
}
println!("a binding: {}", a_binding);
let another_binding;
// Error! Use of uninitialized binding
println!("another binding: {}", another_binding);
// FIXME ^ Comment out this line
another_binding = 1;
println!("another binding: {}", another_binding);
}
Swift uses an immutable let
declaration for defining constants. Here's an example from the Swift Book:
var environment = "development"
let maximumNumberOfLoginAttempts: Int
// maximumNumberOfLoginAttempts has no value yet.
if environment == "development" {
maximumNumberOfLoginAttempts = 100
} else {
maximumNumberOfLoginAttempts = 10
}
// Now maximumNumberOfLoginAttempts has a value, and can be read.
Java has a similar feature in the form of blank finals. A blank final uses the final
keyword to indicate that the value of a binding must not change. That binding need not be initialized at declaration and cannot be changed once a value is set. Here's an example from Wikipedia:
final boolean isEven;
if (number % 2 == 0) {
isEven = true;
}
System.out.println(isEven); // compile-error because the variable was not assigned in the else-case.
Java also checks code paths at compile-time to ensure that a blank final's value cannot possibly be set more than once.
One of the early pieces of feedback was, "don't expression statements solve these use cases better?" In some cases that answer is yes, but I don't think that necessarily means that write-once const
can't be a part of toolkit for developers to address these problems.
Here is the approach with write-once const
:
const value;
if (someCondition) {
value = 1;
} else if (someOtherCondition) {
value = 2;
} else {
value = 3;
}
Here is the approach with an imaginary if
expression:
const value = if (someCondition) {
1;
} else if (otherCondition) {
2;
} else {
3;
}
Here is an approach with a proposed do
expression:
const value = do {
if (someCondition) {
1;
} else if (otherCondition) {
2;
} else {
3;
}
}
While if
and do
expressions would solve this problem, there is room to debate which option is more readable.
If JavaScript ends up with a Rust-style match
expression, then I concede that is the best solution for this use case:
const value = match {
when someCondition: 1;
when otherCondition: 2;
default: 3;
}
Here is the approach with write-once const
:
const value1, value2;
if (someCondition) {
value1 = 1;
value2 = 5;
} else if (someOtherCondition) {
value1 = 2;
value2 = 10;
} else {
value1 = 3;
value2 = 15;
}
Here is the approach with an imaginary if
expression:
const { value1, value2 } = if (someCondition) {
({ value1: 1, value2: 5 });
} else if (someOtherCondition) {
({ value1: 2, value2: 10 });
} else {
({ value1: 3, value2: 15 });
}
Here is an approach with a proposed do
expression:
const { value1, value2 } = do {
if (someCondition) {
({ value1: 1, value2: 5 });
} else if (someOtherCondition) {
({ value1: 2, value2: 10 });
} else {
({ value1: 3, value2: 15 });
}
}
The advantage of write-once const
in this scenario is the avoidance of creating temporary objects in order to initialize multiple variables.
Here is the approach with write-once const
:
async function doSomething() {
const result;
try {
result = await someOperationThatMightFail();
} catch (error) {
// handle error and...
return;
}
doSomethingWith(result);
}
Here is the approach with an imaginary try
expression:
async function doSomething() {
const result = try {
await someOperationThatMightFail();
} catch (error) {
// handle error and...
return;
}
doSomethingWith(result);
}
Here is an approach with a proposed do
expression:
async function doSomething() {
const result = do {
try {
await someOperationThatMightFail();
} catch (error) {
// handle error and...
return;
}
}
doSomethingWith(result);
}
For this use case, I think the try
expression is what I'd prefer as a developer overall as it has the clearest indication of what is happening. I'd put write-once const
in second place in this list.
The first version of this proposal threw an error when an uninitialized binding was read in order to match the TDZ behavior of const
and let
as originally defined. Since that time, I've received feedback that the TDZ behavior will likely be removed from the language and so it will no longer make sense to throw an error for an uninitialized write-once const
.
Write-once const
is significally smaller in scope to solve similar problems. My hope is that, if accepted, this proposal could move more quickly through to acceptance. For reference:
do
Expressions (stage 1) were first proposed in 2018 and hasn't had an update in three years.- Pattern Matching (stage 1) was first proposed in 2020 and last presented in 2022.
Plus, Rust and Swift support both write-once bindings and if
, switch
, etc. exprssions -- it seems that there's plenty of room for both solutions in a language.
All of the use cases can definitely be rewritten to use functions that encapsulate the logic, however, that's not what developers are actually doing. They're using let
because they can't use const
, and they're not wanting to extract that logic outside of the current function to do so.
- Make your changes to
spec.emu
(ecmarkup uses HTML syntax, but is not HTML, so I strongly suggest not naming it “.html”) - Any commit that makes meaningful changes to the spec, should run
npm run build
to verify that the build will succeed and the output looks as expected. - Whenever you update
ecmarkup
, runnpm run build
to verify that the build will succeed and the output looks as expected.