Skip to content

nzakas/proposal-write-once-const

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

12 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Write-Once const Declarations

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.

Motivation

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.

Use Case #1: Multiple conditions to calculate a value

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.

Use Case #2: Assigning to Multiple Bindings

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:

Use Case #3: try-catch-initialized Bindings

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:

Proposal

I propose that const declarations no longer require initialization. Specifically:

  1. An uninitialized const declaration is no longer a syntax error.
  2. Attempting to read an uninitialized const binding before its value is returns undefined.
  3. An uninitialized const binding may have its value set exactly once.
  4. Attempting to set the value of an uninitialized const binding more than once causes the same TypeError: Assignment to constant variable. as assigning to any other const binding.
  5. Using typeof on an uninitialized const 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

Prior Art

Other languages supporting write-once immutable bindings typically have the following behavior:

  1. The binding cannot be read until a value is set (this is an error)
  2. 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

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

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

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.

Comparison to Various Expression-Based Solutions

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.

Use Case #1: Multiple conditions to calculate a value

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;
}

Use Case #2: Assigning to Multiple Bindings

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.

Use Case #3: try-catch-initialized Bindings

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.

Frequently Asked Questions

Why not throw an error when the uninitialized binding is read?

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.

Why propose this when people seem to be clamoring for expression statements?

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.

Why didn't you show using functions to address these use cases?

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.

Maintain your proposal repo

  1. Make your changes to spec.emu (ecmarkup uses HTML syntax, but is not HTML, so I strongly suggest not naming it “.html”)
  2. 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.
  3. Whenever you update ecmarkup, run npm run build to verify that the build will succeed and the output looks as expected.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published