• Stars
    star
    2,496
  • Rank 18,403 (Top 0.4 %)
  • Language
    HTML
  • Created over 5 years ago
  • Updated 11 months ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

ECMAScript proposal for the Record and Tuple value types. | Stage 2: it will change!

JavaScript Records & Tuples Proposal

Authors:

  • Robin Ricard (Bloomberg)
  • Rick Button (Bloomberg)
  • Nicolรฒ Ribaudo (Babel)

Champions:

  • Robin Ricard (Bloomberg)
  • Rick Button (Bloomberg)

Advisors

  • Philipp Dunkel (Bloomberg)
  • Dan Ehrenberg (Bloomberg)
  • Maxwell Heiber

Stage: 2

Try out Record and Tuple in the playground!

Spec Text

Tutorial

Cookbook

Content

  1. Overview
  2. Examples
  3. Syntax
  4. Equality
  5. The object model
  6. Record and Tuple standard library support
  7. Rationale

Overview

This proposal introduces two new deeply immutable data structures to JavaScript:

  • Record, a deeply immutable Object-like structure #{ x: 1, y: 2 }
  • Tuple, a deeply immutable Array-like structure #[1, 2, 3, 4]

Records and Tuples can only contain primitives and other Records and Tuples. You could think of Records and Tuples as "compound primitives". By being thoroughly based on primitives, not objects, Records and Tuples are deeply immutable.

Records and Tuples support comfortable idioms for construction, manipulation and use, similar to working with objects and Arrays. They are compared deeply by their contents, rather than by their identity.

JavaScript engines may perform certain optimizations on construction, manipulation and comparison of Records and Tuples, analogous to the way Strings are often implemented in JS engines. (It should be understood that these optimizations are not guaranteed.)

Records and Tuples aim to be usable and understood with external typesystem supersets such as TypeScript or Flow.

Prior work on immutable data structures in JavaScript

Today, userland libraries implement similar concepts, such as Immutable.js. Also a previous proposal has been attempted but abandoned because of the complexity of the proposal and lack of sufficient use cases.

This new proposal is still inspired by this previous proposal but introduces some significant changes: Record and Tuples are now deeply immutable. This property is fundamentally based on the observation that, in large projects, the risk of mixing immutable and mutable data structures grows as the amount of data being stored and passed around grows as well so you'll be more likely handling large record & tuple structures. This can introduce hard-to-find bugs.

As a built-in, deeply immutable data structure, this proposal also offers a few usability advantages compared to userland libraries:

  • Records and Tuples are easily introspectable in a debugger, while library provided immutable types are often hard to inspect as you have to inspect through data structure details.
  • Because they're accessed through typical object and array idioms, no additional branching is needed in order to write a generic library that consumes both immutable and JS objects; with user libraries, method calls may be needed just in the immutable case.
  • We avoid cases where developers may expensively convert between regular JS objects and immutable structures, by making it easier to just always use the immutable ones.

Immer is a notable approach to immutable data structures, and prescribes a pattern for manipulation through producers and reducers. It is not providing immutable data types however, as it generates frozen objects. This same pattern can be adapted to the structures defined in this proposal in addition to frozen objects.

Deep equality as defined in user libraries can vary significantly, in part due to possible references to mutable objects. By drawing a hard line about only deeply containing primitives, Records and Tuples, and recursing through the entire structure, this proposal defines simple, unified semantics for comparisons.

Examples

Record

const proposal = #{
  id: 1234,
  title: "Record & Tuple proposal",
  contents: `...`,
  // tuples are primitive types so you can put them in records:
  keywords: #["ecma", "tc39", "proposal", "record", "tuple"],
};

// Accessing keys like you would with objects!
console.log(proposal.title); // Record & Tuple proposal
console.log(proposal.keywords[1]); // tc39

// Spread like objects!
const proposal2 = #{
  ...proposal,
  title: "Stage 2: Record & Tuple",
};
console.log(proposal2.title); // Stage 2: Record & Tuple
console.log(proposal2.keywords[1]); // tc39

// Object functions work on Records:
console.log(Object.keys(proposal)); // ["contents", "id", "keywords", "title"]

Open in playground

Functions can handle Records and Objects in generally the same way:

const ship1 = #{ x: 1, y: 2 };
// ship2 is an ordinary object:
const ship2 = { x: -1, y: 3 };

function move(start, deltaX, deltaY) {
  // we always return a record after moving
  return #{
    x: start.x + deltaX,
    y: start.y + deltaY,
  };
}

const ship1Moved = move(ship1, 1, 0);
// passing an ordinary object to move() still works:
const ship2Moved = move(ship2, 3, -1);

console.log(ship1Moved === ship2Moved); // true
// ship1 and ship2 have the same coordinates after moving

Open in playground

See more examples here.

Tuple

const measures = #[42, 12, 67, "measure error: foo happened"];

// Accessing indices like you would with arrays!
console.log(measures[0]); // 42
console.log(measures[3]); // measure error: foo happened

// Slice and spread like arrays!
const correctedMeasures = #[
  ...measures.slice(0, measures.length - 1),
  -1
];
console.log(correctedMeasures[0]); // 42
console.log(correctedMeasures[3]); // -1

// or use the .with() shorthand for the same result:
const correctedMeasures2 = measures.with(3, -1);
console.log(correctedMeasures2[0]); // 42
console.log(correctedMeasures2[3]); // -1

// Tuples support methods similar to Arrays
console.log(correctedMeasures2.map(x => x + 1)); // #[43, 13, 68, 0]

Open in playground

Similarly than with records, we can treat tuples as array-like:

const ship1 = #[1, 2];
// ship2 is an array:
const ship2 = [-1, 3];

function move(start, deltaX, deltaY) {
  // we always return a tuple after moving
  return #[
    start[0] + deltaX,
    start[1] + deltaY,
  ];
}

const ship1Moved = move(ship1, 1, 0);
// passing an array to move() still works:
const ship2Moved = move(ship2, 3, -1);

console.log(ship1Moved === ship2Moved); // true
// ship1 and ship2 have the same coordinates after moving

Open in playground

See more examples here.

Forbidden cases

As stated before Record & Tuple are deeply immutable: attempting to insert an object in them will result in a TypeError:

const instance = new MyClass();
const constContainer = #{
    instance: instance
};
// TypeError: Record literals may only contain primitives, Records and Tuples

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

tuple.map(x => new MyClass(x));
// TypeError: Callback to Tuple.prototype.map may only return primitives, Records or Tuples

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

Syntax

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

We define a record or tuple expression by using the # modifier in front of otherwise normal object or array expressions.

Examples

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

Syntax errors

Holes are prevented in syntax, unlike Arrays, which allow holes. See issue #84 for more discussion.

const x = #[,]; // SyntaxError, holes are disallowed by syntax

Using the __proto__ identifier as a property is prevented in syntax. See issue #46 for more discussion.

const x = #{ __proto__: foo }; // SyntaxError, __proto__ identifier prevented by syntax

const y = #{ ["__proto__"]: foo }; // valid, creates a record with a "__proto__" property.

Concise methods are disallowed in Record syntax.

#{ method() { } }  // SyntaxError

Runtime errors

Records may only have String keys, not Symbol keys, due to the issues described in #15. Creating a Record with a Symbol key is a TypeError.

const record = #{ [Symbol()]: #{} };
// TypeError: Record may only have string as keys

Records and Tuples may only contain primitives and other Records and Tuples. Attempting to create a Record or Tuple that contains an Object (null is not an object) or a Function throws a TypeError.

const obj = {};
const record = #{ prop: obj }; // TypeError: Record may only contain primitive values

Equality

Equality of Records and Tuples works like that of other JS primitive types like Boolean and String values, comparing by contents, not identity:

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

This is distinct from how equality works for JS objects: comparison of objects will observe that each object is distinct:

assert({ a: 1 } !== { a: 1 });
assert(Object(#{ a: 1 }) !== Object(#{ a: 1 }));
assert(Object(#[1, 2]) !== Object(#[1, 2]));

Insertion order of record keys does not affect equality of records, because there's no way to observe the original ordering of the keys, as they're implicitly sorted:

assert(#{ a: 1, b: 2 } === #{ b: 2, a: 1 });

Object.keys(#{ a: 1, b: 2 })  // ["a", "b"]
Object.keys(#{ b: 2, a: 1 })  // ["a", "b"]

If their structure and contents are deeply identical, then Record and Tuple values considered equal according to all of the equality operations: Object.is, ==, ===, and the internal SameValueZero algorithm (used for comparing keys of Maps and Sets). They differ in terms of how -0 is treated:

  • Object.is treats -0 and 0 as unequal
  • ==, === and SameValueZero treat -0 with 0 as equal

Note that == and === are more direct about other kinds of values nested in Records and Tuples--returning true if and only if the contents are identical (with the exception of 0/-0). This directness has implications for NaN as well as comparisons across types. See examples below.

See further discussion in #65.

assert(#{ a:  1 } === #{ a: 1 });
assert(#[1] === #[1]);

assert(#{ a: -0 } === #{ a: +0 });
assert(#[-0] === #[+0]);
assert(#{ a: NaN } === #{ a: NaN });
assert(#[NaN] === #[NaN]);

assert(#{ a: -0 } == #{ a: +0 });
assert(#[-0] == #[+0]);
assert(#{ a: NaN } == #{ a: NaN });
assert(#[NaN] == #[NaN]);
assert(#[1] != #["1"]);

assert(!Object.is(#{ a: -0 }, #{ a: +0 }));
assert(!Object.is(#[-0], #[+0]));
assert(Object.is(#{ a: NaN }, #{ a: NaN }));
assert(Object.is(#[NaN], #[NaN]));

// Map keys are compared with the SameValueZero algorithm
assert(new Map().set(#{ a: 1 }, true).get(#{ a: 1 }));
assert(new Map().set(#[1], true).get(#[1]));
assert(new Map().set(#[-0], true).get(#[0]));

The object model of Record and Tuple

In general, you can treat Records like objects. For example, the Object namespace and the in operator work with Records.

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

Advanced internal details: Record and Tuple wrapper objects

JS developers will typically not have to think about Record and Tuple wrapper objects, but they're a key part of how Records and Tuples work "under the hood" in the JavaScript specification.

Accessing of a Record or Tuple via . or [] follows the typical GetValue semantics, which implicitly converts to an instance of the corresponding wrapper type. You can also do the conversion explicitly through Object():

  • Object(record) creates a Record wrapper object
  • Object(tuple) creates a Tuple wrapper object

(One could imagine that new Record or new Tuple could create these wrappers, like new Number and new String do, but Records and Tuples follow the newer convention set by Symbol and BigInt, making these cases throw, as it's not the path we want to encourage programmers to take.)

Record and Tuple wrapper objects have all of their own properties with the attributes writable: false, enumerable: true, configurable: false. The wrapper object is not extensible. All put together, they behave as frozen objects. This is different from existing wrapper objects in JavaScript, but is necessary to give the kinds of errors you'd expect from ordinary manipulations on Records and Tuples.

An instance of Record has the same keys and values as the underlying record value. The __proto__ of each of these Record wrapper objects is null (discussion: #71).

An instance of Tuple has keys that are integers corresponding to each index in the underlying tuple value. The value for each of these keys is the corresponding value in the original tuple. In addition, there is a non-enumerable length key. Overall, these properties match those of the String wrapper object. That is, Object.getOwnPropertyDescriptors(Object(#["a", "b"])) and Object.getOwnPropertyDescriptors(Object("ab")) each return an object that looks like this:

{
  "0": {
    "value": "a",
    "writable": false,
    "enumerable": true,
    "configurable": false
  },
  "1": {
    "value": "b",
    "writable": false,
    "enumerable": true,
    "configurable": false
  },
  "length": {
    "value": 2,
    "writable": false,
    "enumerable": false,
    "configurable": false
  }
}

The __proto__ of Tuple wrapper objects is Tuple.prototype. Note that, if you're working across different JavaScript global objects ("Realms"), the Tuple.prototype is selected based on the current Realm when the Object conversion is performed, similarly to how the .prototype of other primitives behaves -- it's not attached to the Tuple value itself. Tuple.prototype has various methods on it, analogous to Arrays.

For integrity, out-of-bounds numerical indexing on Tuples returns undefined, rather than forwarding up through the prototype chain, as with TypedArrays. Lookup of non-numerical property keys forwards up to Tuple.prototype, which is important to find their Array-like methods.

Record and Tuple standard library support

Tuple values have functionality broadly analogous to Array. Similarly, Record values are supported by different Object static methods.

assert.deepEqual(Object.keys(#{ a: 1, b: 2 }), ["a", "b"]);
assert(#[1, 2, 3].map(x => x * 2), #[2, 4, 6]);

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

Converting from Objects and Arrays

You can convert structures using Record(), Tuple() (with the spread operator), Record.fromEntries() or Tuple.from():

const record = Record({ a: 1, b: 2, c: 3 });
const record2 = Record.fromEntries([["a", 1], #["b", 2], { 0: 'c', 1: 3 }]); // note that any iterable of entries will work
const tuple = Tuple(...[1, 2, 3]);
const tuple2 = Tuple.from([1, 2, 3]); // note that an iterable will also work

assert(record === #{ a: 1, b: 2, c: 3 });
assert(tuple === #[1, 2, 3]);
Record({ 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 Record(), Tuple(), Record.fromEntries() and Tuple.from() expect collections consisting of Records, Tuples or other primitives (such as Numbers, Strings, etc). Nested object references would cause a TypeError. It's up to the caller to convert inner structures in whatever way is appropriate for the application.

Note: The current draft proposal does not contain recursive conversion routines, only shallow ones. See discussion in #122

Iteration protocol

Like Arrays, Tuples are iterable.

const tuple = #[1, 2];

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

Similarly to Objects, Records are only iterable in conjunction with APIs like Object.entries.

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

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

// Object.entries can be used to iterate over Records, just like for Objects
// output is:
// a
// b
for (const [key, value] of Object.entries(record)) { console.log(key) }

JSON.stringify

  • The behavior of JSON.stringify(record) is equivalent to calling JSON.stringify on the object resulting from recursively converting the record to an object that contains no records or tuples.
  • The behavior of JSON.stringify(tuple) is equivalent to calling JSON.stringify on the array resulting from recursively converting the tuple to an array that contains no records or tuples.
JSON.stringify(#{ a: #[1, 2, 3] }); // '{"a":[1,2,3]}'
JSON.stringify(#[true, #{ a: #[1, 2, 3] }]); // '[true,{"a":[1,2,3]}]'

JSON.parseImmutable

Please see https://github.com/tc39/proposal-json-parseimmutable

Tuple.prototype

Tuple supports instance methods similar to Array with a few changes:

  • The mechanics of Tuple and Array methods are a bit different; Array methods generally depend on being able to incrementally modify the Array, and are built for subclassing, neither of which would apply for Tuples.
  • Operations which mutate the Array are not supported. For example, there is no Tuple.prototype.push method.
  • Tuples include the methods introduced by the Change Array by copy proposal, such as Tuple.prototype.withAt.

The appendix contains a full description of Tuple's prototype.

typeof

typeof identifies Records and Tuples as distinct types:

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

Usage in {Map|Set|WeakMap|WeakSet}

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 here, they are compared by value.

It is not possible to use a Record or Tuple as a key in a WeakMap or as a value in a WeakSet, because Records and Tuples are not Objects, and their lifetime is not observable.

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 and WeakSet

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

WeakSet

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

// TypeError: Can't add a Record to a WeakSet
weakSet.add(record);

Rationale

Why introduce new primitive types? Why not just use objects in an immutable data structure library?

One core benefit of the Records and Tuples proposal is that they are compared by their contents, not their identity. At the same time, === in JavaScript on objects has very clear, consistent semantics: to compare the objects by identity. Making Records and Tuples primitives enables comparison based on their values.

At a high level, the object/primitive distinction helps form a hard line between the deeply immutable, context-free, identity-free world and the world of mutable objects above it. This category split makes the design and mental model clearer.

An alternative to implementing Record and Tuple as primitives would be to use operator overloading to achieve a similar result, by implementing an overloaded abstract equality (==) operator that deeply compares objects. While this is possible, it doesn't satisfy the full use case, because operator overloading doesn't provide an override for the === operator. We want the strict equality (===) operator to be a reliable check of "identity" for objects and "observable value" (modulo -0/+0/NaN) for primitive types.

Another option is to perform what is called interning: we track globally Record or Tuple objects and if we attempt to create a new one that happens to be identical to an existing Record object, we now reference this existing Record instead of creating a new one. This is essentially what the polyfill does. We're now equating value and identity. This approach creates problems once we extend that behavior across multiple JavaScript contexts and wouldn't give deep immutability by nature and it is particularly slow which would make using Record & Tuple a performance-negative choice.

Will developers be familiar with this new concept?

Record & Tuple is built to interoperate with objects and arrays well: you can read them exactly the same way as you would do with objects and arrays. The main change lies in the deep immutability and the comparison by value instead of identity.

Developers used to manipulating objects in an immutable manner (such as transforming pieces of Redux state) will be able to continue to do the same manipulations they used to do on objects and arrays, this time, with more guarantees.

We are going to do empirical research through interviews and surveys to figure out if this is working out as we think it does.

Why are Record & Tuple not based on .get()/.set() methods like Immutable.js?

If we want to keep access to Record & Tuple similar to Objects and Arrays as described in the previous section, we can't rely on methods to perform that access. Doing so would require us to branch code when trying to create a "generic" function able to take Objects/Arrays/Records/Tuples.

Here is an example function that has support for Immutable.js Records and ordinary objects:

const ProfileRecord = Immutable.Record({
    name: "Anonymous",
    githubHandle: null,
});

const profileObject = {
    name: "Rick Button",
    githubHandle: "rickbutton",
};
const profileRecord = ProfileRecord({
    name: "Robin Ricard",
    githubHandle: "rricard",
});

function getGithubUrl(profile) {
    if (Immutable.Record.isRecord(profile)) {
        return `https://github.com/${
            profile.get("githubHandle")
        }`;
    }
    return `https://github.com/${
        profile.githubHandle
    }`;
}

console.log(getGithubUrl(profileObject)) // https://github.com/rickbutton
console.log(getGithubUrl(profileRecord)) // https://github.com/rricard

This is error-prone as both branches could easily get out of sync over time...

Here is how we would write that function taking Records from this proposal and ordinary objects:

const profileObject = {
  name: "Rick Button",
  githubHandle: "rickbutton",
};
const profileRecord = #{
  name: "Robin Ricard",
  githubHandle: "rricard",
};

function getGithubUrl(profile) {
  return `https://github.com/${
    profile.githubHandle
  }`;
}

console.log(getGithubUrl(profileObject)) // https://github.com/rickbutton
console.log(getGithubUrl(profileRecord)) // https://github.com/rricard

This function support both Objects and Records in a single code-path as well as not forcing the consumer to choose which data structures to use.

Why do we need to support both at the same time anyway? This is primarily to avoid an ecosystem split. Let's say we're using Immutable.js to do our state management but we need to feed our state to a few external libraries that don't support it:

state.jobResult = Immutable.fromJS(
    ExternalLib.processJob(
        state.jobDescription.toJS()
    )
);

Both toJS() and fromJS() can end up being very expensive operations depending on the size of the substructures. An ecosystem split means conversions that, in turn, means possible performance issues.

Why introduce new syntax? Why not just introduce the Record and Tuple globals?

The proposed syntax significantly improves the ergonomics of using Record and Tuple in code. For example:

// with the proposed syntax
const record = #{
  a: #{
    foo: "string",
  },
  b: #{
    bar: 123,
  },
  c: #{
    baz: #{
      hello: #[
        1,
        2,
        3,
      ],
    },
  },
};

// with only the Record/Tuple globals
const record = Record({
  a: Record({
    foo: "string",
  }),
  b: Record({
    bar: 123,
  }),
  c: Record({
    baz: Record({
      hello: Tuple(
        1,
        2,
        3,
      ),
    }),
  }),
});

The proposed syntax is intended to be simpler and easier to understand, because it is intentionally similar to syntax for object and array literals. This takes advantage of the user's existing familiarity with objects and arrays. Additionally, the second example introduces additional temporary object literals, which adds to complexity of the expression.

Why specifically the #{}/#[] 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 theoretically 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. Currently, the champion group is leaning towards #[]/#{}, but discussion is ongoing in #10.

Why deep immutability?

The definition of Record & Tuple as compound primitives forces everything in Record & Tuple to not be objects. This comes with some drawbacks (referencing objects becomes harder but is still possible) but also more guarantees to avoid common programming mistakes.

const object = {
   a: {
       foo: "bar",
   },
};
Object.freeze(object);
func(object);

// func is able to mutate objectโ€™s keys even if object is frozen

In the above example, we try to create a guarantee of immutability with Object.freeze. Unfortunately, since we did not freeze the object deeply, nothing tells us that object.a has been untouched. With Record & Tuple that constraint is by nature and there is no doubt that the structure is untouched:

const record = #{
   a: #{
       foo: "bar",
   },
};
func(record);
// runtime guarantees that record is entirely unchanged
assert(record.a.foo === "bar");

Finally, deep immutability suppresses the need for a common pattern which consists of deep-cloning objects to keep guarantees:

const clonedObject = JSON.parse(JSON.stringify(object));
func(clonedObject);
// now func can have side effects on clonedObject, object is untouched
// but at what cost?
assert(object.a.foo === "bar");

FAQ

How can I make a Record or Tuple which is based on an existing one, but with one part changed or added?

In general, the spread operator works well for this:

// Add a Record field
let rec = #{ a: 1, x: 5 }
#{ ...rec, b: 2 }  // #{ a: 1, b: 2, x: 5 }

// Change a Record field
#{ ...rec, x: 6 }  // #{ a: 1, x: 6 }

// Append to a Tuple
let tup = #[1, 2, 3];
#[...tup, 4]  // #[1, 2, 3, 4]

// Prepend to a Tuple
#[0, ...tup]  // #[0, 1, 2, 3]

// Prepend and append to a Tuple
#[0, ...tup, 4]  // #[0, 1, 2, 3, 4]

And if you're changing something in a Tuple, the Tuple.prototype.with method works:

// Change a Tuple index
let tup = #[1, 2, 3];
tup.with(1, 500)  // #[1, 500, 3]

Some manipulations of "deep paths" can be a bit awkward. For that, the Deep Path Properties for Records proposal adds additional shorthand syntax to Record literals.

We are developing the deep path properties proposal as a separate follow-on proposal because we don't see it as core to using Records, which work well independently. It's the kind of syntactic addition which would work well to prototype over time in transpilers, and where we have many decision points which don't have to do with Records and Tuples (e.g., how it works with objects).

How does this relate to the Readonly Collections proposal?

We've talked with the Readonly Collections champions, and both groups agree that these are complements:

  • Readonly collections are shallowly immutable and may point to objects; they may be mutated during construction, and read-only views of mutating objects are supported.
  • Records and Tuples are deeply immutable and consist only of primitives.

Neither one is a subset of the other in terms of functionality. At best, they are parallel, just like each proposal is parallel to other collection types in the language.

So, the two champion groups have resolved to ensure that the proposals are in parallel with respect to each other. For example, this proposal adds a new Tuple.prototype.withReversed method. The idea would be to check, during the design process, if this signature would also make sense for read-only Arrays (if those exist): we extracted these new methods to the Change Array by copy proposal, so that we can discuss an API which builds a consistent, shared mental model.

In the current proposal drafts, there aren't any overlapping types for the same kind of data, but both proposals could grow in these directions in the future, and we're trying to think these things through ahead of time. Who knows, some day TC39 could decide to add primitive RecordMap and RecordSet types, as the deeply immutable versions of Set and Map! And these would be in parallel with Readonly Collections types.

Could we have classes whose instances are Records?

TC39 has been long discussing "value types", which would be some kind of class declaration for a primitive type, for several years, on and off. An earlier version of this proposal even made an attempt. This proposal tries to start off simple and minimal, providing just the core structures. The hope is that it could provide the data model for a future proposal for classes.

This proposal is loosely related to a broader set of proposals, including operator overloading and extended numeric literals: These all conspire to provide a way for user-defined types to do the same as BigInt. However, the idea is to add these features if we determine they're independently motivated.

If we had user-defined primitive/value types, then it could make sense to use them in built-in features, such as CSS Typed OM or the Temporal Proposal. However, this is far in the future, if it ever happens; for now, it works well to use objects for these sorts of features.

What's the relationship between this proposal's Record & Tuple and TypeScript's Record & Tuple?

Although both kinds of Records relate to Objects, and both kinds of Tuples relate to Arrays, that's about where the similarity ends.

Records in TypeScript are a generic utility type to represent an object taking a key type matching with a value type. They still represent objects.

Likewise, Tuples in TypeScript are a notation to express types in an array of a limited size (starting with TypeScript 4.0 they have a variadic form). Tuples in TypeScript are a way to express arrays with heterogeneous types. ECMAScript tuples can correspond to TS arrays or TS tuples easily as they can either contain an indefinite number of values of the same type or contain a limited number of values with different types.

TS Records or Tuples are orthogonal features to ECMAScript Records and Tuples and both could be expressed at the same time:

const record: Readonly<Record<string, number>> = #{
  foo: 1,
  bar: 2,
};
const tuple: readonly [number, string] = #[1, "foo"];

What are the performance expectations of these data structures?

This proposal does not make any performance guarantees and does not require specific optimizations in implementations. Based on feedback from implementers, it is expected that they will implement common operations via "linear time" algorithms. However, this proposal does not prevent some classical optimizations for purely functional data structures, including but not limited to:

  • Optimizations for making deep equality checks fast:
    • For returning true quickly, intern ("hash-cons") some data structures
    • For returning false quickly, maintain a hash of the tree of the contents of some structures
  • Optimizations for manipulating data structures
    • In some cases, reuse existing data structures (e.g., when manipulated with object spread), similar to ropes or typical implementations of functional data structures
    • In other cases, as determined by the engine, use a flat representation like existing JavaScript object implementations

These optimizations are analogous to the way that modern JavaScript engines handle string concatenation, with various different internal types of strings. The validity of these optimizations rests on the unobservability of the identity of records and tuples. It's not expected that all engines will act identically with respect to these optimizations, but rather, they will each make decisions about which particular heuristics to use. Before Stage 4 of this proposal, we plan to publish a guide for best practices for cross-engine optimizable use of Records and Tuples, based on the implementation experience that we will have at that point.

Glossary

Record

A new, deeply immutable, compound primitive type data structure, proposed in this document, that is analogous to Object. #{ a: 1, b: 2 }

Tuple

A new, deeply immutable, compound primitive type data structure, proposed in this document, that is analogous to Array. #[1, 2, 3, 4]

Compound primitive types

Values which act like other JavaScript primitives, but are composed of other constituent values. This document proposes the first two compound primitive types: Record and Tuple.

Simple primitive types

String, Number, Boolean, undefined, null, Symbol and BigInt

Primitive types

Things which are either compound or simple primitive types. All primitives in JavaScript share certain properties:

  • They are deeply immutable
  • Comparison is by value, not by identity
  • They are not objects in the object model--object operations lead to exceptions or implicit wrapper creation.

Immutable Data Structure

A data structure that doesn't accept operations that change it internally, but instead has operations that return a new value that is the result of applying that operation to it.

In this proposal Record and Tuple are deeply immutable data structures.

Strict Equality

The operator === is defined with the Strict Equality Comparison algorithm. Strict Equality refers to this particular notion of equality.

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.

More Repositories

1

proposals

Tracking ECMAScript Proposals
17,177
star
2

ecma262

Status, process, and documents for ECMA-262
HTML
14,437
star
3

proposal-pipeline-operator

A proposal for adding a useful pipe operator to JavaScript.
HTML
7,534
star
4

proposal-pattern-matching

Pattern matching syntax for ECMAScript
HTML
5,498
star
5

proposal-optional-chaining

HTML
4,942
star
6

proposal-type-annotations

ECMAScript proposal for type syntax that is erased - Stage 1
JavaScript
4,252
star
7

proposal-signals

A proposal to add signals to JavaScript.
3,387
star
8

proposal-temporal

Provides standard objects and functions for working with dates and times.
HTML
3,321
star
9

proposal-observable

Observables for ECMAScript
JavaScript
3,058
star
10

proposal-decorators

Decorators for ES6 classes
2,640
star
11

test262

Official ECMAScript Conformance Test Suite
JavaScript
2,073
star
12

proposal-dynamic-import

import() proposal for JavaScript
HTML
1,863
star
13

proposal-bind-operator

This-Binding Syntax for ECMAScript
1,742
star
14

proposal-class-fields

Orthogonally-informed combination of public and private fields proposals
HTML
1,722
star
15

proposal-async-await

Async/await for ECMAScript
HTML
1,578
star
16

proposal-object-rest-spread

Rest/Spread Properties for ECMAScript
HTML
1,493
star
17

proposal-shadowrealm

ECMAScript Proposal, specs, and reference implementation for Realms
HTML
1,429
star
18

proposal-iterator-helpers

Methods for working with iterators in ECMAScript
HTML
1,307
star
19

proposal-nullish-coalescing

Nullish coalescing proposal x ?? y
HTML
1,232
star
20

proposal-top-level-await

top-level `await` proposal for ECMAScript (stage 4)
HTML
1,083
star
21

proposal-partial-application

Proposal to add partial application to ECMAScript
HTML
1,002
star
22

proposal-do-expressions

Proposal for `do` expressions
HTML
990
star
23

proposal-binary-ast

Binary AST proposal for ECMAScript
961
star
24

agendas

TC39 meeting agendas
JavaScript
952
star
25

proposal-built-in-modules

HTML
891
star
26

proposal-async-iteration

Asynchronous iteration for JavaScript
HTML
857
star
27

proposal-explicit-resource-management

ECMAScript Explicit Resource Management
JavaScript
746
star
28

proposal-set-methods

Proposal for new Set methods in JS
HTML
655
star
29

proposal-string-dedent

TC39 Proposal to remove common leading indentation from multiline template strings
HTML
614
star
30

proposal-operator-overloading

JavaScript
610
star
31

proposal-import-attributes

Proposal for syntax to import ES modules with assertions
HTML
591
star
32

proposal-async-context

Async Context for JavaScript
HTML
587
star
33

proposal-bigint

Arbitrary precision integers in JavaScript
HTML
561
star
34

ecmascript_simd

SIMD numeric type for EcmaScript
JavaScript
540
star
35

ecma402

Status, process, and documents for ECMA 402
HTML
529
star
36

proposal-slice-notation

HTML
523
star
37

proposal-change-array-by-copy

Provides additional methods on Array.prototype and TypedArray.prototype to enable changes on the array by returning a new copy of it with the change.
HTML
511
star
38

notes

TC39 meeting notes
JavaScript
496
star
39

proposal-class-public-fields

Stage 2 proposal for public class fields in ECMAScript
HTML
489
star
40

proposal-iterator.range

A proposal for ECMAScript to add a built-in Iterator.range()
HTML
483
star
41

proposal-decimal

Built-in exact decimal numbers for JavaScript
HTML
477
star
42

proposal-uuid

UUID proposal for ECMAScript (Stage 1)
JavaScript
463
star
43

proposal-module-expressions

HTML
433
star
44

proposal-throw-expressions

Proposal for ECMAScript 'throw' expressions
JavaScript
425
star
45

proposal-UnambiguousJavaScriptGrammar

413
star
46

proposal-weakrefs

WeakRefs
HTML
409
star
47

proposal-array-grouping

A proposal to make grouping of array items easier
HTML
407
star
48

proposal-error-cause

TC39 proposal for accumulating errors
HTML
380
star
49

proposal-cancelable-promises

Former home of the now-withdrawn cancelable promises proposal for JavaScript
Shell
376
star
50

proposal-ecmascript-sharedmem

Shared memory and atomics for ECMAscript
HTML
374
star
51

proposal-module-declarations

JavaScript Module Declarations
HTML
369
star
52

proposal-first-class-protocols

a proposal to bring protocol-based interfaces to ECMAScript users
352
star
53

proposal-relative-indexing-method

A TC39 proposal to add an .at() method to all the basic indexable classes (Array, String, TypedArray)
HTML
351
star
54

proposal-global

ECMAScript Proposal, specs, and reference implementation for `global`
HTML
346
star
55

proposal-private-methods

Private methods and getter/setters for ES6 classes
HTML
345
star
56

proposal-numeric-separator

A proposal to add numeric literal separators in JavaScript.
HTML
330
star
57

proposal-private-fields

A Private Fields Proposal for ECMAScript
HTML
319
star
58

tc39.github.io

Get involved in specifying JavaScript
HTML
318
star
59

proposal-object-from-entries

TC39 proposal for Object.fromEntries
HTML
318
star
60

proposal-promise-allSettled

ECMAScript Proposal, specs, and reference implementation for Promise.allSettled
HTML
314
star
61

proposal-await.ops

Introduce await.all / await.race / await.allSettled / await.any to simplify the usage of Promises
HTML
310
star
62

proposal-regex-escaping

Proposal for investigating RegExp escaping for the ECMAScript standard
JavaScript
309
star
63

proposal-export-default-from

Proposal to add `export v from "mod";` to ECMAScript.
HTML
306
star
64

proposal-logical-assignment

A proposal to combine Logical Operators and Assignment Expressions
HTML
302
star
65

proposal-promise-finally

ECMAScript Proposal, specs, and reference implementation for Promise.prototype.finally
HTML
279
star
66

proposal-json-modules

Proposal to import JSON files as modules
HTML
272
star
67

proposal-asset-references

Proposal to ECMAScript to add first-class location references relative to a module
270
star
68

proposal-cancellation

Proposal for a Cancellation API for ECMAScript
HTML
267
star
69

proposal-promise-with-resolvers

HTML
255
star
70

proposal-string-replaceall

ECMAScript proposal: String.prototype.replaceAll
HTML
253
star
71

proposal-export-ns-from

Proposal to add `export * as ns from "mod";` to ECMAScript.
HTML
242
star
72

proposal-structs

JavaScript Structs: Fixed Layout Objects
230
star
73

proposal-ses

Draft proposal for SES (Secure EcmaScript)
HTML
223
star
74

proposal-intl-relative-time

`Intl.RelativeTimeFormat` specification [draft]
HTML
215
star
75

proposal-json-parse-with-source

Proposal for extending JSON.parse to expose input source text.
HTML
214
star
76

proposal-flatMap

proposal for flatten and flatMap on arrays
HTML
214
star
77

proposal-defer-import-eval

A proposal for introducing a way to defer evaluate of a module
HTML
208
star
78

ecmarkup

An HTML superset/Markdown subset source format for ECMAScript and related specifications
TypeScript
201
star
79

proposal-promise-any

ECMAScript proposal: Promise.any
HTML
200
star
80

proposal-optional-chaining-assignment

`a?.b = c` proposal
186
star
81

proposal-decorators-previous

Decorators for ECMAScript
HTML
184
star
82

proposal-smart-pipelines

Old archived draft proposal for smart pipelines. Go to the new Hack-pipes proposal at js-choi/proposal-hack-pipes.
HTML
181
star
83

proposal-array-from-async

Draft specification for a proposed Array.fromAsync method in JavaScript.
HTML
178
star
84

proposal-upsert

ECMAScript Proposal, specs, and reference implementation for Map.prototype.upsert
HTML
176
star
85

proposal-collection-methods

HTML
171
star
86

proposal-array-filtering

A proposal to make filtering arrays easier
HTML
171
star
87

proposal-ptc-syntax

Discussion and specification for an explicit syntactic opt-in for Tail Calls.
HTML
169
star
88

proposal-extractors

Extractors for ECMAScript
JavaScript
166
star
89

proposal-error-stacks

ECMAScript Proposal, specs, and reference implementation for Error.prototype.stack / System.getStack
HTML
166
star
90

proposal-intl-duration-format

164
star
91

how-we-work

Documentation of how TC39 operates and how to participate
161
star
92

proposal-Array.prototype.includes

Spec, tests, reference implementation, and docs for ESnext-track Array.prototype.includes
HTML
157
star
93

proposal-promise-try

ECMAScript Proposal, specs, and reference implementation for Promise.try
HTML
154
star
94

proposal-extensions

Extensions proposal for ECMAScript
HTML
150
star
95

proposal-hashbang

#! for JS
HTML
148
star
96

proposal-import-meta

import.meta proposal for JavaScript
HTML
146
star
97

proposal-intl-segmenter

Unicode text segmentation for ECMAScript
HTML
146
star
98

proposal-resizablearraybuffer

Proposal for resizable array buffers
HTML
145
star
99

proposal-seeded-random

Proposal for an options argument to be added to JS's Math.random() function, and some options to start it with.
HTML
143
star
100

eshost

A uniform wrapper around a multitude of ECMAScript hosts. CLI: https://github.com/bterlson/eshost-cli
JavaScript
142
star