Skip to content

Control child Microstate creation #357

@cowboyd

Description

@cowboyd

There are many case where we need to specify how a child microstate is created from the parent. A simple case is where you want a microstate to have a default value if none is specified. For example, if I have a counter that I want to start at 1, I would use create + the default value.

class Counter {
  count = create(Number, 1);
}

Another example is one where we want to pass a value down from one microstate to another. For example, if we need to pass the bluetooth service id down to one of its characteristics, then we'd have to use an initializer.

class Service {
  characteristics = [Characteristic]

  initialize(value) {
    if (this.characteristics.length > 0 && value.characteristics[0].serviceUUID == null) {
      return value.characteristics.map(c => Object.assign({}, c, {serviceUUID: value.uuid}))
    }
    return this;
  }
}

This is ugly, and while there are other ways to achieve this, it's not very clear what's going on. Particularly because we are having to fiddle with the parent value in order to achieve a result in the child microstate.

So we have two one-off use-cases that leverage existing features that were not really created explicitly for those reasons, but we have to use them because they're kinda just laying around.

What if instead we had a general mechanism for determining how microstates are related to each other, and what is better for a general interface than a function? What if we had a function called relationship that was used to specify how to traverse a property from a parent microstate to a child microstate? We could use this for both of the use cases above.

To set a default value:

import { relationship } from 'microstates';

class Counter {
  count = relationship((counter, value) => create(Number, value || 1));
}

the relationship function receives the parent instance (counter instanceof Counter) and the value of the relationship, and is expected to return a microstate representing the relationship.

We can use this low-level function to write a "macro" relationship like defaults that does what we did above, except more intent-fully.

import { relationship } from 'microstates';

function defaults(Type, defaultValue) {
  return relationship((parent, value) => create(Type, value == null ? defaultValue : value));
}

class Counter {
  count = defaults(Number, 1);
}

We can also use this low-level mechanism for copying values down the tree:

class Service {
  characteristics = relationship((service, values) => create([Characteristic], values.map(v => append(v, { serviceUUID: service.uuid });
}

Again though, we can use a "macro" relationship to define this cleanly:

function copyIntoEach(Type, mappings) {
  return relationship((parent, values) => {
    let copies = Object.keys(mappings).reduce((copies, key) => append(copies, {[key]: valueOf(parent)[key])
    return create([Type], values.map(v => append(v, copies))
  });
}

class Service {
  characteristic = copyIntoEach(Characteristic, {uuid: 'serviceUUID'})
}

The implementation of copyIntoEach isn't the important thing, merely the fact that we can declaratively define really complex relationships with a little work.

Advantages of this approach is that it.

  1. lead the way for references into different parts of the data structure.
  2. will give us rich metadata about the names and types of the relationship since they are now first-class entities.
  3. ought to be compatible with decorators if they ever land :)
  4. fully compatible with TypeScript out of the gate.

Open questions

  • composability: what if we want to make a relationship that has a default value and copies values from the parent?
  • What other use-cases are there? To see if this approach couldn't get us there.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions