[heycam/webidl] A new approach to array-ish classes (#796)

For a long time we've had some issues with arrays/indexed getter types on the platform. I am beginning to think we should solve this in Web IDL. The below long write-up walks through my thoughts.

/cc @tabatkins @annevk @syg @rakina as folks I've discussed this with previously.

### Background

#### The problem

We've historically had trouble representing array-ish types on the platform. Specifically, there are two cases that are very common in web specs, but JavaScript provides no good solution for:

- Readonly arrays: i.e., arrays whose contents can be changed by the creator of the array, but not by the consumer of the array. Examples where this semantic is useful are `navigator.languages`, `document.styleSheets`, or `node.childNodes`.

- React-to-able arrays: i.e., arrays which are mutable by the consumer, but any mutations need to be validated (usually for type-checking), and potentially trigger some side effect. xamples where this semantic is useful are `element.ariaDescribedByElements` or `document.adoptedStyleSheets`.

JavaScript *does* provide for two cases. Those are: mutable arrays that you don't need to react to (just `Array`), and completely immutable arrays (frozen `Array`s, i.e. `Array`s which have had `Object.freeze()` called on them). But the above two cases are not in reach.

#### Historical approaches

We have tried two main solutions to these use cases:

- Array-ish classes, i.e. Web IDL interfaces with indexed getters and possibly setters. Examples include `StyleSheetList` and `NodeList` for the readonly case, or `HTMLOptionsCollection` for the react-to-able case.

  These suck in various ways, in roughly decreasing order of terribleness:
  - They do not have any useful array methods like `map()`, `filter()`, `find()`, ...
  - They often have weird methods that duplicate array behavior, like `add()`, `remove()`, or `item()`
  - They are not `instanceof Array`
  - `Array.isArray()` returns false on them
  - `someArray.concat(oneOfThose)` does not concatenate, but instead adds `oneOfThose` as a single new item

- The `FrozenArray` type. Examples include `navigator.languages` and `document.adoptedStyleSheets`. This is generally seen as the preferred modern pattern. In particular, all of the cons of the array-ish classes are avoided.

  However, frozen arrays are suboptimal in the following ways:
  - For the readonly case: references you obtain from the getter can get stale. That is, if you do `const langs = navigator.languages`, `langs` will not get updated if the UA's languages change. Instead, `navigator.languages` will start returning a new frozen array instance, such that `navigator.languages !== langs`. This can be somewhat of a footgun.
  
  - For the react-to-able case: mutating the arrays must be done via the setter. E.g. to insert, you'd do something like `document.adoptedStyleSheets = [...document.adoptedStyleSheets, newSheet]`. This is less pleasant than being able to use normal array mutation methods, e.g. `document.adoptedStyleSheets.push(newSheet)`. (It's also less efficient, although in most cases the small JS-object-related cost here is outweighed by the cost of the actual reaction, e.g. style recalc or DOM mutation.)

### Solutions

#### Ideal, TC39 solution

I think the best outcome here would be if TC39 enabled array creators to install set hooks on the array. Then you'd implement readonly arrays via something like

```js
const arr = Array.withSetHook(() => {
  if (!specialBooleanOnlyIControl) {
    throw new TypeError("This array is readonly");
  }
});

// In use code:
arr.push(1); // throws

// In array creator code:
specialBooleanOnlyIControl = true;
arr.push(1);
specialBooleanOnlyIControl = false;
```

and you'd implement react-to-able arrays via something like

```js
const arr = Array.withSetHook((value, i) => {
  if (isWrongType(value)) {
    throw new TypeError("Can only accept CSSStyleSheet instances or whatever");
  }
  
  reactToTheNewCSSStyleSheet(value);
});

arr.push(1);                  // throws
arr.push(new CSStyleSheet()); // works and gets reacted to
```

I mentioned this to @syg and he was hesitant from an implementation complexity perspective in the JS engine. He suggested we just use proxies instead. Which brings us to...

#### Solving this in Web IDL

The essence of this solution is to basically do the "array-ish classes" thing, but better. If we just design two specific array-ish classes, designed from the very beginning to not be terrible, we can get pretty close to optimal. And then instead of spec authors continuing to re-invent new bad array-ish classes using indexed getters/setters, we would just tell them to always use these two, and get the benefits of all our design work.

We would define two types in Web IDL: `ReadonlyArray` and `ReactToAbleArray`. (OK, we probably need a better name for the last one.) One version of this would be something like

```webidl
interface ReadonlyArrayController {
  void splice(unsigned long long start, unsigned long long deleteCount, any... items);
  // we could have more mutating methods here if we wanted
};
callback ReadonlyArrayExecutor = void (ReadonlyArrayController controller);

[LegacyArrayClass]
interface ReadonlyArray {
  constructor(ReadonlyArrayExecutor executor);
  getter any (unsigned long long index);
};
```

```webidl
callback ReactToAbleArrayReactor = void (any item, unsigned long long index);

[LegacyArrayClass]
interface ReactToAbleArray {
  constructor(ReactToAbleArrayReactor reactor);
  getter any (unsigned long long index);
  setter void (unsigned long long index, any value);
};
```

We probably want to set the `@@species` of these to `Array`, so that `oneOfThese.map(...)` and `oneOfThese.filter(...)` produce `Array`s instead of `ReadonlyArray`s or `ReactToAbleArray`s.

We probably also to set the `@@isConcatSpreadable` of these to `true`, so that `array.concat(oneOfThese)` properly concatenates.

Note that the above designs will still fail `Array.isArray()`. There's no way to pass that check without getting changes to the 262 spec.

Once we defined these types, we could evangelize their usage everywhere. We could likely also upgrade many of the existing cases on the platform to use them, which would transparently give web developers more useful array types, e.g. allowing `document.adoptedStyleSheets.push()` or maybe even `nodeList.filter()`.

##### Variants and tweaks

We could consolidate these into a single type, the `ReactToAbleArray`. It'd be more awkward to use (you need the `specialBooleanOnlyIControl` technique), but at least for spec authors we could paper over that with some Web IDL-provided boilerplate phrases. The web developer-facing differences when consuming the array would be relatively minimal; worse error messages or debugging experience would be the only thing I can think of.

We probably should add a type parameter to these, e.g. `ReactToAbleArray<T>`, since type-filtering is so prevalent on the web platform.

Instead of using `[LegacyArrayClass]`, i.e. instead of inheriting from `Array.prototype` and using all its methods directly, we could install appropriate methods directly on the two types. (Including omitting mutation methods from `ReadonlyArray`.) This would increase our maintenance burden in the Web IDL spec a bit, requiring us to stay in sync with 262. And it would make these classes fail `instanceof Array` checks. But it could be a bit nicer for implementations in some ways, I suspect.

-- 
You are receiving this because you are subscribed to this thread.
Reply to this email directly or view it on GitHub:
https://github.com/heycam/webidl/issues/796

Received on Thursday, 12 September 2019 09:48:02 UTC