Classifying objects without “class”

Andrew Gibson
5 min readSep 20, 2023

--

Event-driven patterns in JavaScript shine light into the dustier corners of the language. This article explores customising the behaviour of the instanceof operator.

A system I’m working on uses message-passing extensively. Each message has a “type” or “class”, and some associated properties.

// 2 yoyos were added to the cart
const addedToCart = AddedToCart({ ...yoyo, quantity: 2 });
addedToCart.quantity // 2
addedToCart.sku // "KAY-123-YOLO"

The classic way

Someone from a C#, Java or C++ background might reach for a class at this point. And, JavaScript supports this quite well. As an added bonus we can now use the class to detect the type of our message using instanceof:

class AddedToCart {
constructor({ sku, price, quantity }) {
this.sku = sku;
this.price = price;
this.quantity = quantity;
}
}

const addedToCart = new AddedToCart({ ...yoyo, quantity: 2 });
addedToCart.quantity; // 2
addedToCart.sku; // "KAY-123-YOLO"
addedToCart instanceof AddedToCart; // true

Some codebases and teams are happy with this approach. I try to fit in with the paradigm that exists. As long as it is working for the team.

But, I prefer to think of classes as distinguished by their behaviour. I avoid using a class as a marker of an object’s structure. In C# 9 and Java 14, they introduced record types, which are a better fit. But, we don’t have those in JavaScript.

WWTD? (What would TypeScripters do?)

TypeScript is all about removing the need to defer type checking. So, as a TypeScripter, I might try to avoid this design altogether. But, TypeScript is a flexible language so there are many ways we could approach it.

Lets look at just one — interfaces. Interfaces are great because they get erased during compilation. But, TypeScript uses a “structural” type system, and we need a discriminator field. _tag is sometimes used for this use case.

const addedToCartType = "AddedToCart"

type AddedToCartType = typeof addedToCartType

interface AddedToCart {
_tag: AddedToCartType
sku: string
price: number
quantity: number
}

function isAddedToCart(thing: object): thing is AddedToCart {
return (thing as AddedToCart)._tag === addedToCartType
}

const addedToCart: AddedToCart = { _tag: addedToCartType, ...yoyo, quantity: 2 }
isAddedToCart(addedToCart) // true

Systems which use discriminator fields sometimes reserve the top level of the data structure. For example, Actions in flux / redux are structured this way:

{ 
type: “AddedToCart”,
payload: { sku: "12341234", price: 10.99, quantity: 2 }
}

I don’t particularly like this pattern (or the TypeScript one above) because of the use of strings. It’s ok for a few well known values. But, if I have 20 types of message in the system, it gets a bit messy.

I could use a convention such as namespaces. But I’d prefer the language to guarantee uniqueness for me.

JavaScript has Symbols for just this type of situation, but (afaik) I can’t use a symbol instead of a string in a TypeScript interface.

Trying something a bit different

What I want is some way to create a data structure with simple syntax. But, I’d also like to use the instanceof operator to match it’s type against a classification / taxonomy. Fortunately, JavaScript lets us do that.

The spec for instanceof states the standard behaviour as follows:

The abstract operation InstanceofOperator takes arguments V . . . and target . . . determining if V is an instance of target either by consulting target’s @@hasInstance method or, if absent, determining whether the value of target’s “prototype” property is present in V’s prototype chain.

So, I can do something like this:


// message building
const type = Symbol("Message type");

const message = (messageType, props) => ({
...props,
[type]: messageType
});

const messagePrototype = (messageType) => ({

[Symbol.hasInstance]: obj => obj?.[type] === messageType

});


// taxonomy
const addedToCartType = Symbol("Added to cart");
const AddedToCart = Object.setPrototypeOf(
{},
messagePrototype(addedToCartType)
);


// usage
const addedToCart = message(addedToCartType, { ...yoyo, quantity: 2 });

addedToCart.quantity // 2
addedToCart.sku // "KAY-123-YOLO"
addedToCart instanceof AddedToCart // true
  • addedToCartType is the “internal” marker of the type of the message
  • AddedToCart is our published taxonomy entry — the “external” type of the message
  • messagePrototype is a function which knits the two together

Cleaning up the syntax

At this point we’re already close to the syntax shown at the beginning:

const addedToCart = AddedToCart({ ...yoyo, quantity: 2 });

addedToCart.quantity // 2
addedToCart.sku // "KAY-123-YOLO"
addedToCart instanceof AddedToCart // true

The remaining task is to reduce our dependency on three variables (addedToCartType, AddedToCart and message) to just one. Because functions in JavaScript are objects, we can combine all three:


// message building
const type = Symbol("Message type");

const message = (messageType, props) => ({
...props,
[type]: messageType
});

const messagePrototype = (messageType) => ({

[Symbol.hasInstance]: obj => obj?.[type] === messageType

});

function messageBuilder(description) {

const messageType = Symbol(description);
const builder = props => message(messageType, props);
Object.setPrototypeOf(builder, messagePrototype(messageType));
return builder;

}


// taxonomy
const AddedToCart = messageBuilder("Added to cart");


// usage
const addedToCart = AddedToCart({ ...yoyo, quantity: 2 });

addedToCart.quantity // 2
addedToCart.sku // "KAY-123-YOLO"
addedToCart instanceof AddedToCart // true

There are a couple of things to note here:

  1. setPrototypeOf and related functions are relatively slow in javascript engines. It’s important that these calls only happen once (when the taxonomy is created).
  2. the instanceof keyword in older javascript engines (prior to 2016) doesn’t work as we need it to. If you need to support older versions, you might need to fallback to a class based version, or the old __proto__ property.

Practical examples

I lay out the above logic in ECMAScript modules (and some usage examples) here: https://github.com/goofballLogic/modd/tree/article-2023/articles/002_5-Messages

More coverage of this feature can be found on MDN.

--

--