Tagged records (and variants)

This proposal is intended to be a follow-on proposal for record/tuples. It's loosely inspired by a similar "struct" idea that I had brought up previously as part of a separate thread, but I decided to break it out and simplify it a bunch.

Here's the basics of how it works:

// Create a new tag
const userTag = new Tag()

// Tag a record
// tagging a record that's already been tagged will
// cause the old tag to be stripped off.
const user = userTag(#{ name: 'Sally', age: 23 })

// Checking if a record is tagged by the userTag
userTag.isTagging(user) // true
userTag.isTagging(#{ x: 2 }) // false
user === userTag(#{ name: 'Sally', age: 23 }) // true
user === #{ name: 'Sally', age: 23 } // false

The ability to tag a record like this provides the end-user with a tool that can be used to solve a wide variety of problems. Here are a couple of specific benefits that come from having a tagging system.

Brand Checking

The ability to tag records provides the end-user with the ability to know, with certainty, who created a particular record. Take this example:

// user.js
const userTag = new Tag()

export const create = ({ name, age }) => userTag(#{
  name,
  age,
  birthday: getBirthdayFromAge(age),
})

export const isUser = record => userTag.isTagging(record)

Notice in the above example, the userTag is never exported. This means, the only way an outside user can create a record that's been tagged with the userTag is via the exported create function. At any point in time, the end-user can utilize the isUser() function to verify that a record they hold was, in fact, created by this user module. If it was, they can know with certainty that, for example, the birthday and age values are correctly in sync.

Variants

Variants are a much-loved feature among functional programmers, and they can easily be emulated via tagged records. For the purposes of this example, we're going to assume that Tag() can accept a validation function - this isn't necessary, but it could be a nice-to-have feature. If validation fails, an error will be thrown.

// Emulating a variant
const maybe = {
  just: new Tag({
    validate: record => (
      Object.keys(record).length === 1 &&
      'value' in record
    ),
  }),
  nothing: new Tag({
    validate: record => Object.keys(record).length === 0,
  }),
}

maybe.just(#{ x: 2 }) // Error: Failed the validation check.

function findInArray(array, predicate) {
  for (const element of array) {
    if (predicate(element)) {
      return maybe.just(#{ value: element })
    }
  }
  return maybe.nothing(#{})
}

findInArray([3, 4, 5], x => x % 2 === 0).value // 4

// We could allow tags to work with the pattern matching proposal as well
match (findInArray([3, 4, 5], x => x % 2 === 0)) {
  when (maybe.just with { value }) { value }
  when (maybe.nothing) { throw new Error('Value not found!') }
}

Typed superset of JavaScript, like TypeScript, can add a lot of additional value to this proposal, e.g. by letting you provide a type definition for each variant case, and by requiring match statements to exhaustively account for all cases of a variant, so if you ever add a new variant case, your compiler can warn you of all places in your code that you need to update.

For reference, I'll also point out this other thread which also tried to introduce variants, but in a significantly different way.

Other notes

It's technically possible to emulate this concept of tags to a degree in userland. Check out the previous discussion where this idea came from to see some ways in which it can be done via WeakMap, if you're willing to put a box into your record. It's just not easy or convenient to do this kind of thing. Having a tagging system as a first-class language feature puts a lot more power at your fingertips.

https://github.com/syg/proposal-structs ?

I like how that struct proposal lets you declare the shape of the object.

That proposal in general does seem to mostly focus on the concept of shared structs. The non-shared struct part of it seems to be mostly "I bet this concept is also useful if we had some of the shared restrictions lifted", but what's left doesn't feel that much different from just doing an Object.seal(this) inside your constructor. I opened an issue here to try and learn a little more about it.

Related conversation starting here about putting something private (unforgeable) in a Record to validate who created it. There is an example using GitHub - tc39/proposal-private-declarations: A proposal to allow trusted code _outside_ of the class lexical scope to access private state and also more complex demonstrations that use existing features.

Worth noting that the complexities of the emulated-tagging via weak references and boxes could be 'hidden' away into a reusable userland library, which should make it easier and more convenient.

One option would be to wait to see if such a library takes off, and proves popular before it's directly added to the language.

I think I like that idea better - of allowing private fields within a record/tuple. If feels like a really natural extension to that private declarations proposal.

You would easily be able to get brand checking:

private #isUser;

export const create = ({ name, age }) => #{
  outer #isUser: true,
  name,
  age,
  birthday: getBirthdayFromAge(age),
}

export const isUser = record => #isUser in record

It doesn't help too much with the variant use case, but that was just an extra nice-to-have that naturally came out of this tagging idea. And, who knows, maybe record tagging would be the best way to eventually get a better variant concept in JavaScript, or some other proposal.

1 Like

Using GitHub - tc39/proposal-private-declarations: A proposal to allow trusted code _outside_ of the class lexical scope to access private state is definitely a lot less code. It does open up some other questions, usually private-fields are purely internal, JS code that does not have access to the private field can not detect the existence of it *ref.

But if a record has a private field then it's existence will likely be observable.

let privRec = (() => {
  private #a;
  return #{ outer #a: 1, b : 2 };
})():

let normRec = #{ b: 2 };

// They seem to be identical
Object.is(#[...Object.entries(privRec)], #[...Object.entries(normRec)]); 

// but
new Set([privRec , normRec]).size === 2;

Whereas the approach that adds a Symbol/Box to a record to 'tag it' - while it does requires more code, the directly visibility of the Symbol/Box from the outside would make how equality is determined more straight forward, in that it does not introduce any new equality cases to be handled.

hmm... this is true, and an interesting point. Another weird point would be the fact that the two records could both have a private entry "#a" with different values, and that would make them non-equal.

I guess my initial tag proposal handles this case pretty nicely as well. The fact that it's tagged would be public knowledge. It would still, unfortunately, pass that specific equality check, but it could fail a dumber one, like this:

String(record1) === String(record2)

As, the name of the tag could be made part of the string output. So, at the very least, if you have some records that you thought would be equal, and you print them out for more information, you would see something like this that'll tip you off as to what was happening.

UserTag #{ x: 2 }
AnotherTag #{ x: 2 }
#{ x: 2 }

I guess another option to make the private declaration just as powerful in this regard would be to include the fact that private fields exist in the string output. Something like this:

#{ ...<privateFields>, public: 2 }

None of this is ideal, but it's perhaps a little better.

I'd be a bit worried about putting private fields in R/T. As mentioned on github, all this can be done explicitly in user land by wrapping the target in a record, and including an opaque identity carrying primitive (Tag) to identify the brand.

A Tag is basically like a symbol which can be used to convey identity and be used in Weak collections:

function Tag(description) {
  const tagString = `Tag(${description})`;
  const tagObject = Object.assign(Object.create(null), {
    description: description.valueOf(),
    toString: Object.freeze(() => tagString),
    valueOf: Object.freeze(() => tagBox),
  });
  Object.defineProperty(tagObject, "toString", { enumerable: false });
  Object.defineProperty(tagObject, "valueOf", { enumerable: false });
  Object.freeze(tagObject);
  const tagBox = Box(tagObject);
  return tagBox;
}

Regarding tagging, there are 2 use cases I see:

  • The tagged data should be visible and participate in the structure checks of R/T that may contain it. In that case the tagging primitive would be a Boxed unique object, and the "public marker" would keep track of these tagged records:
class PublicMarker() {
  #marked = new WeakSet();
  #tag;

  constructor(description) {
    this.#tag = new Tag(description);
  }

  get mark() {
    return this.#tag;
  }
  
  wrap(record) {
    const marked = #{mark: this.#tag, wrapped: record};
    this.#marked.add(marked);
    return marked;
  }

  unwrap(marked) {
    if (!this.isWrapped(marked)) throw new TypeError();
    return marked.wrapped;
  }

  isWrapped(record) {
    return record.mark === this.#tag && this.#marked.has(record);
  }
}
  • The tagged data should be opaque and privately wrapped so that only the wrapper can unwrap it. The wrapped data would be contained in a Box to exclude it from participating in sameStructure checks.
class PrivateMarker {
  #Wrapper = new WrapperRegistry();
  #tag;

  constructor(description) {
    this.#tag = new Tag(description);
  }

  get mark() {
    return this.#tag;
  }
  
  wrap(record) {
    const wrapped = Box(this.#Wrapper.wrap(value));
    return #{mark: this.#tag, wrapped};
  }

  unwrap(marked) {
    if (!this.isWrapped(marked)) throw new TypeError();
    return this.#Wrapper.unwrap(Box.unbox(marked.wrapped));
  }

  isWrapped(record) {
    try {
      const correctShape =
        Record.isRecord(marked) &&
        #[...Object.keys(marked)] === #['mark', 'wrapped'] &&
        marked.mark === this.#tag;

      return correctShape && Box.unbox(marked.wrapped) instanceof this.#Wrapper;
    } catch {
      return false;
    }
  }
}

The WrapperRegistry would be this one: Wrap any value into a registered object ยท GitHub

1 Like

I can understand the worry for not wanting private fields in records. It does make the equality check awkward when you can't even see what you're comparing with. Once you start putting private fields in your record, then you're probably just using records primarily for their immutability aspect, not their comparability, and perhaps records wouldn't be the best solution.

If all you're caring about is immutability, and not comparability, then perhaps a frozen object with a private declaration would be a better choice over a record. Anyone who's wanting to use it in a record can simply put it in a box. It would be convenient to have some built-in tagging system (either making your classes built-in instead of user-defined, or doing something along the lines of what I originally proposed), but one can certainly live with simply boxing objects instead.

I'm considering publishing the WrapperRegistry to NPM, that's the main one that's a bit tricky to implement. Not sure if it'd be acceptable as a built-in. And credits to @aclaymore for the original idea that inspired me! The rest is fairly straight-forward, and in the case of Markers, potentially application specific. Might be interesting to document the Tag and Marker patterns as examples of what can be done with Box.

2 Likes