Skip to content

mheiber/proposal-const-value-types

 
 

Repository files navigation

Const Value Types: Record & Tuple

ECMAScript proposal for the Record and Tuple const value types (also known as immutable types).

Authors:

  • Robin Ricard (Bloomberg)
  • Richard Button (Bloomberg)
  • Philipp Dunkel (Bloomberg)

Champions: TBD

Stage: 0

Overview

The goal of this proposal is to introduce deeply constant/immutable value types to JavaScript. It has multiple objectives:

  • Introducing efficient data structures that makes copying and changing them cheap and will allow programs avoiding mutation of data to run faster (pattern heavily used in Redux for instance).
  • Add guarantees in strict equality when comparing data. This is only possible because those data structures are deeply immutable (comparing props fast is essential for efficient virtual dom reconciliation in React apps for instance)
  • Be easily understood by external typesystem supersets such as TypeScript or Flow.
  • Offers the possibility to improve structured cloning efficiency when messaging across workers.

This proposal presents 2 main additions to the language:

  • Record
  • Tuple

The only valid sub-structures of these values will be one of those structures and normal value types such as number, string, symbol or null.

Prior work on immutable data structures in JavaScript

As of today, a few libraries are actually implementing similar concepts such as Immutable.js or Immer that have been covered by a previous proposal attempt. However, the main influence to that proposal is constant.js that forces data structures to be deeply immutable.

Using libraries to handle those types has multiple issues: we have multiple ways of doing the same thing that do not interoperate with each other, the syntax is not as expressive as it could be if it was integrated in the language and finally, it can be very challenging for a type system to pick up what the library is doing.

Examples

Simple Record

const record1 = #{
    a: 1,
    b: 2,
    c: 3,
};

const record2 = record1 with .b = 5;
const record3 = #{...record1, b: 5};

assert(record1 !== record2);
assert(record2 === #{ a: 1, b: 5, c: 3});
assert(record2 === record3);

Simple Tuple

const tuple1 = #[1, 2, 3];

const tuple2 = tuple1 with [0] = 2;

assert(tuple1 !== tuple2);
assert(tuple2 === #[2, 2, 3]);

const tuple3 = #[1, ...tuple2];

assert(tuple3 === #[1, 2, 2, 3]);

Computed access

const record = #{ a: 1, b: 2, c: 3 };
const tuple = #[1, 2, 3];

const k = "b";
const i = 0;

assert((record with [k] = 5) === #{ a: 1, b: 5, c: 3});
assert((tuple with [i] = 2) === #[2, 2, 3]);

Nested structures

const marketData = #[
    { ticker: "AAPL", lastPrice: 195.855 },
    { ticker: "SPY", lastPrice: 286.53 },
];

const updatedData = marketData
    with [0].lastPrice = 195.891,
         [1].lastPrice = 286.61;

assert(updatedData === #[
    { ticker: "AAPL", lastPrice: 195.891 },
    { ticker: "SPY", lastPrice: 286.61 },
]);

Forbidden cases

const instance = new MyClass();
const constContainer = #{
    instance: instance
};
// TypeError: Can't use a non-const type in a const declaration

const constContainer = #{
    instance: null,
};
constContainer with .instance = new MyClass();
// TypeError: Can't use a non-const type in a const operation

const tuple = #[1, 2, 3];

tuple.map(x => new MyClass(x));
// TypeError: Can't use a non-const type in a const operation

// The following should work:
Array.from(tuple).map(x => new MyClass(x))

More assertions

assert((#{} with .a = 1, .b = 2) === #{ a: 1, b: 2 });
assert((#[ {} ] with [0].a = 1) === #[ { a: 1 } ]);
assert((x = 0, #[ {} ] with [x].a = 1) === #[ { a: 1 } ]);

Syntax

This defines the new pieces of syntax being added to the language with this proposal.

Expressions and Declarations

We define ConstExpression by using the # modifier in front of otherwise normal expressions and declarations.

ConstExpression:

# ObjectExpression

# ArrayExpression

Examples

#{}
#{ a: 1, b: 2 }
#{ a: 1, b: [2, 3, { c: 4 }] }
#[]
#[1, 2]
#[1, 2, { a: 3 }]

Runtime verification

At runtime, if a non-value type is placed inside a Record or Tuple, it is a TypeError. This means that a Record or Tuple expression can only contain value types.

Const update expression

ConstAssignment:

.Identifier = Expression

[Expression] = Expression

.MemberExpression = Expression

[Expression]MemberExpression = Expression

ConstCall:

.CallExpression

ConstUpdatePart:

ConstAssignment

ConstUpdatePart, ConstUpdatePart

ConstUpdateExpression:

Identifier with ConstUpdatePart

Examples

record with .a = 1
record with .a = 1, .b = 2
tuple with [0] = 1
record with .a.b = 1
record with ["a"]["b"] = 1

Runtime verification

The same runtime verification will apply. It is a TypeError when a value inside a Record or Tuple is updated with a non-value type.

Record and Tuple boxing objects

We add to the global namespace two boxing objects that you can use to manipulate those value types. Those boxing objects have multiple properties that are in line with how those value types behave.

Instantiation and converting from non-const types

You can't instantiate (as in, getting a reference of) any Record or Tuple so using new will throw a TypeError. However, you can convert any structure that can be deeply represented as const using Record.from() or Tuple.from() available in the global namespace:

const record = Record({ a: 1, b: 2, c: 3 });
const record2 = Record.fromEntries([#["a", 1], #["b", 2], #["c": 3]]); // note that an iterable will also work
const tuple = Tuple.from([1, 2, 3]); // note that an iterable will also work
asset(record === #{ a: 1, b: 2, c: 3 });
asset(tuple === #[1, 2, 3]);
Record.from({ a: {} }); // TypeError: Can't convert Object with a non-const value to Record
Tuple.from([{}, {} , {}]); // TypeError: Can't convert Iterable with a non-const value to Tuple

Note that the whole structure needs to be shallowly convertable to any acceptable value type at runtime. This means that any of the values supported in the array/iterable/object must be one of these: Record, Tuple, number, string, symbol or null.

Note: adding a recursive way of converting data structures should be possible, as long as we introduce a way to control the depth of the conversion.

Record and Tuple namespace

As we've seen previously, we have a Record and Tuple namespace. The Record global namespace has some associated functions similar to Object. Same goes for Tuple and Array. The Object namespace and the in operator should also be able to work with Records and return as usual equivalent. For instance:

assert(Record.keys(#{ a: 1, b: 2 }) === #["a", "b"]);
const keysArr = Object.keys(#{ a: 1, b: 2 }); // returns the array ["a", "b"]
assert(keysArr[0] === "a");
assert(keysArr[1] === "b");
assert(keysArr !== #["a", "b"]);
assert("a" in #{ a: 1, b: 2 });

See the appendix to learn more about the Record & Tuple namespaces.

Ordering of properties

This part is an open question, we did not decide yet what is going to be the behavior and will try to gather additional feedback before proceeding:

Alphabetical Order (option 1)

When the properties of a Record or Tuple are enumerated, their keys are enumerated in sorted order. This differs from regular objects, where insertion order is preserved when enumerating properties (except for properties that parse as numerics, where the behavior is undefined).

const obj = { z: 1, a: 1 };
const record = #{ z: 1, a: 1 };

Object.keys(obj); // ["z", "a"]
Record.keys(record); // #["a", "z"]

The properties of Records and Tuples are enumerated in this sorted order in order to preserve their equality when consuming them in pure functions.

const record1 = #{ a: 1, z: 1 };
const record2 = #{ z: 1, a: 1 };

const func = (record) => {...} // some function with no observable side effects

assert(record1 === record2);
assert(func(record1) === func(record2));

If enumeration order for Records and Tuples was instead insertion order, then: const func = Record.keys; would break the above assertion.

Insertion Order (option 2)

When the properties of a Record or Tuple are enumerated, their keys are enumerated in the insertion/last update order. This has the consequence of making the inserting order matter for strict equality because now the insertion order is actual differentiating information.

const obj = { z: 1, a: 1 };
const record = #{ z: 1, a: 1 };

Object.keys(obj); // ["z", "a"]
Record.keys(record); // #["z", "a"]

const record2 = #{ a: 1, z: 1 };
assert(record !== record2);
assert(record === record2 with .a = 1);

This option is being considered as it could be beneficial in implementing it into javascript engines (such data structure is very similar to hidden classes, "shapes" in SpiderMonkey, "maps" in V8).

Iteration of properties

Similar to objects and arrays, Records are not iterable, while Tuples are iterable. For example:

const record = #{ a: 1, b: 2 };
const tuple = #[1, 2];

// TypeError: record is not iterable
for (const o of record) { console.log(o); }


// output is:
// 1
// 2
for (const o of tuple) { console.log(o); }

Record prototype

The Record prototype is null.

Tuple prototype

The Tuple prototype is an object that contains the same methods as Array with a few changes:

  • Tuple.prototype.pop() and Tuple.prototype.shift() do not return the removed element, they return the result of the change
  • Tuple.prototype.first() and Tuple.prototype.last() are added to return the first and last element of the Tuple

See the appendix Tuple's prototype.

typeof

The typeof operator will return a new value for Records and Tuples. The value to be returned is still an open question. For now, we think that "record" is the most reasonable option.

assert(typeof #{ a: 1 } === "record");
assert(typeof #[1, 2]   === "record");

Usage in {Map|Set|WeakMap}

It is possible to use a Record or Tuple as a key in a Map, and as a value in a Set. When using a Record or Tuple in this way, key/value equality behaves as expected.

It is not possible to use a Record or Tuple as a key in a WeakMap, because Records and Tuples are not Objects, and their lifetime is not observable. Attempting to set a value in a WeakMap using a Record or Tuple as the key will result in a TypeError.

Examples

Map

const record1 = #{ a: 1, b: 2 };
const record2 = #{ a: 1, b: 2 };

const map = new Map();
map.set(record1, true);
assert(map.get(record2));

Set

const record1 = #{ a: 1, b: 2 };
const record2 = #{ a: 1, b: 2 };

const set = new Set();
set.add(record1);
set.add(record2);
assert(set.size === 1);

WeakMap

const record = #{ a: 1, b: 2 };
const weakMap = new WeakMap();

// TypeError: Can't use a Record as the key in a WeakMap
weakMap.set(record, true);

FAQ

Why #{}/#[] syntax? What about an existing or new keyword?

Using a keyword as a prefix to the standard object/array literal syntax presents issues around backwards compatibility. Additionally, re-using existing keywords can introduce ambiguity.

ECMAScript defines a set of reserved keywords that can be used for future extensions to the language. Defining a new keyword that is not already reserved is possible, but requires significant effort to validate that the new keyword will not likely break backwards compatibility.

Using a reserved keyword makes this process easier, but it is not a perfect solution because there are no reserved keywords that match the "intent" of the feature, other than const. The const keyword is also tricky, because it describes a similar concept (variable reference immutability) while this proposal intends to add new immutable data structures. While immutability is the common thread between these two features, there has been significant community feedback that indicates that using const in both contexts is undesirable.

Instead of using a keyword, {| |} and [||] have been suggested as possible alternatives. For example:

const first = {| a: 1, b: 2 |};
const second = [|1, 2, 3|];

This syntax also avoids the problems with using a keyword. However, it is also used by Flow as the syntax for exact object types. Investigation will need to be done to determine if introducing this syntax in ECMAScript will break existing Flow typings.

How does this relate to the const keyword?

const variable declarations and Record/Tuple are completely orthogonal features.

const variable declarations force the reference or value type to stay constant for a given identifier in a given lexical scope.

The Record and Tuple value types are deeply constant and unchangeable.

Using both at the same time is possible, but using a non-const variable declaration is also possible:

const record = #{ a: 1, b: 2 };
let record2 = record with .c = 3;
record2 = record2 with .a = 3, .b = 3;

Record/Tuple equality vs normal equality

assert(#{ a: 1 } === #{ a: 1 });
assert(Object(#{ a: 1 }) !== Object(#{ a: 1 }));
assert({ a: 1 } !== { a: 1 });

Since we established that these value types are completely and deeply constant, if they have the same values stored, they will be considered strictly equal.

It is not the case with normal objects, those objects are instantiated in memory and strict comparison will see that both objects are located at different addresses, they are not strictly equal.

What about const classes?

"Const" classes are being considered as a followup proposal that would let us associate methods to Records.

You can see an attempt at defining them in an earlier version of this proposal.

Are there any follow up proposals being considered?

As this proposal adds a new concept to the language, we expect that other proposals might use this proposal to extend an another orthogonal feature.

We consider exploring the following proposals once this one gets considered for higher stages:

  • "Const" classes
  • ConstSet and ConstMap, the const versions of Set and Map

A goal of the broader set of proposals (including operator overloading and extended numeric literals is to provide a way for user-defined types to do the same as BigInt.

If const classes are standardized, features like Temporal Proposal which might be able to express its types using const classes. However, this is far in the future, and we do not encourage people to wait for the addition of const classes.

What is different with this proposal than with previous attempts?

The main difference is that this proposal has a proper assignment operation using with. This difference makes it possible to handle proper type support, which was not possible with the former proposal.

Would those matters be solved by a library and operator overloading?

Not quite since the with operation does some more advanced things such as being able to deeply change and return a value, for instance:

const newState = state with .settings.theme = "dark";

Even with operator overloading we wouldn't be able to perform such operation.

Glossary

Immutable Data Structure

A Data Structure that doesn't accept operations that change it internally, it has operations that return a new value type that is the result of applying that operation on it.

In this proposal Record and Tuple are immutable data structures.

Strict Equality

In this proposal we define strict equality as it is broadly defined in JavaScript. The operator === is a strict equality operator.

Structural Sharing

Structural sharing is a technique used to limit the memory footprint of immutable data structures. In a nutshell, when applying an operation to derive a new version of an immutable structure, structural sharing will attempt to keep most of the internal structure intact and used by both the old and derived versions of that structure. This greatly limits the amount to copy to derive the new structure.

Value type

In this proposal it defines any of those: boolean, number, symbol, string, undefined, null, Record and Tuple.

Value types can only contain other value types: because of that, two value types with the same contents are strictly equal.

About

A proposal for immutable data structures in JavaScript | ⚠ Stage 0: it will change!

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published