Deep Path Properties in Record Literals

ECMAScript proposal for deep paths properties for Record literals.

Author:

  • Rick Button (Bloomberg)

Champions:

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

Advisors:

  • Dan Ehrenberg (Igalia)

Stage: 1, Reached at June 2020 TC39

Overview

Record literals sometimes include deeply nested structures, but the syntax for describing them (either as a fresh value, or based on a previous value via spread syntax) can be cumbersome and/or verbose. Deep path properties for Record literals provides a solution to this problem, by introducing a new syntax for describing deeply nested structures in a more succinct and readable way.

Examples

These examples demonstrate a possible syntax for deep path properties for Record literals.

const state1 = #{
    counters: #[
        #{ name: "Counter 1", value: 1 },
        #{ name: "Counter 2", value: 0 },
        #{ name: "Counter 3", value: 123 },
    ],
    metadata: #{
        lastUpdate: 1584382969000,
    },
};

const state2 = #{
    ...state1,
    counters[0].value: 2,
    counters[1].value: 1,
    metadata.lastUpdate: 1584383011300,
};

assert(state2.counters[0].value === 2);
assert(state2.counters[1].value === 1);
assert(state2.metadata.lastUpdate === 1584383011300);

// As expected, the unmodified values from "spreading" state1 remain in state2.
assert(state2.counters[2].value === 123);

In the previous example, two counters are incremented, and the "lastUpdate" time is updated in the new record state2.

Without deep path properties, state2 can be created in a few different ways:

// With records/tuples and recursive usage of spread syntax
const state2 = #{
    ...state1,
    counters: #[
        #{
            ...state1.counters[0],
            value: 2,
        },
        #{
            ...state1.counters[1],
            value: 1,
        },
        ...state1.counters,
    ],
    metadata: #{
        ...state1.metadata,
        lastUpdate: 1584383011300,
    },
}

// With Immer (and regular objects)
const state2 = Immer.produce(state1, draft => {
    draft.counters[0].value = 2;
    draft.counters[1].value = 1;
    draft.metadata.lastUpdate = 1584383011300;
});

// With Immutable.js (and regular objects)
const immutableState = Immutable.fromJS(state1);
const state2 = immutableState
    .setIn(["counters", 0, "value"], 2)
    .setIn(["counters", 1, "value"], 1)
    .setIn(["metadata", "lastUpdate"], 1584383011300);

A Simple Example

const rec = #{ a.b.c: 123 };
assert(rec === #{ a: #{ b: #{ c: 123 }}});

Computed Deep Path Property Keys

const rec = #{ ["a"]["b"]["c"]: 123 }
assert(rec === #{ a: #{ b: #{ c: 123 }}});

It is possible to mix dot syntax with computed keys.

const b = "b";
const rec = #{ ["a"][b].c: 123 }
assert(rec === #{ a: #{ b: #{ c: 123 }}});

Combining Deep Path Properties with Spread

const one = #{
    a: 1,
    b: #{
        c: #{
            d: 2,
            e: 3,
        }
    }
};
const two = #{
    b.c.d: 4,
    ...one,
};

assert(one.b.c.d === 2);
assert(two.b.c.d === 4);

It is possible to traverse through Tuples.

const one = #{
    a: 1,
    b: #{
        c: #[2, 3, 4, #[5, 6]]
    },
}
const two = #{
    b.c[3][1]: 7,
    ...one,
};

assert(two.b.c === #[2, 3, 4, #[5, 7]]);

FAQ

Does deep path properties syntax support objects?

No, the semantics for deep path properties for objects are much less clear than they are for Record, where (because Records are immutable) the semantics are much simpler.

What happens if the deep path property does not exist in the value that is spread?

A TypeError is thrown. For example:

const one = #{ a: #{} };

#{ ...one, a.b.c: "foo" }; // throws TypeError

#{ ...one, a.b[0]: "foo" }; // also throws TypeError

One might expect that a record or tuple somehow "materializes" in these cases, to fill in the path. However, when deep path property syntax is used with spread syntax, there can be ambiguities in what kind of value to "materialize" if the value doesn't already exist. In the latter example, both of the following expansions seem like valid answers:

#{ a: #{ b: #[123] } }

#{ a: #{ b: #{ 0: 123 } } }

To keep things simple and minimal, attempting to use a deep path property where the path doesn't already exist in the spread value (whether there is this kind of ambiguity or not) throws a TypeError.

What happens if a deep path property attempts to set a non-number-like key on a Tuple

A TypeError is thrown. For example:

const one = #{ a: #[1,2,3] };

#{ ...one, a.foo: 4 }; // throws TypeError

Tuples cannot have non-number-like keys, as they are immutable ordered lists of values and do not have the concept of a "property" (just like a number doesn't have properties). If you attempt to create a Record literal with deep path property syntax that would create a Tuple with a non-number-like key, a TypeError is thrown.

See issue #4 for more discussion.