Writable getters

4 min read 0 comments

Setters removing themselves are reminiscent of Ouroboros, the serpent eating its own tail, an ancient symbol. Media credit

A pattern that has come up a few times in my code is the following: an object has a property which defaults to an expression based on its other properties unless it’s explicitly set, in which case it functions like a normal property. Essentially, the expression functions as a default value.

Some examples of use cases:

Ok, so now that I convinced you about the utility of this pattern, how do we implement it in JS?

Our first attempt may look something like this:

let lea = {
	name: "Lea Verou",
	get id() {
		return this.name.toLowerCase().replace(/\W+/g, "-");
	}
}

Note: We are going to use object literals in this post for simplicity, but the same logic applies to variations using Object.create(), or a class Person of which lea is an instance.

Our first attempt doesn’t quite work as you might expect:

lea.id; // "lea-verou"
lea.id = "lv";
lea.id; // Still "lea-verou"!

Why does this happen? The reason is that the presence of the getter turns the property into an accessor, and thus, it cannot also hold data. If it doesn’t have a setter, then simply nothing happens when it is set.

However, we can have a setter that, when invoked, deletes the accessor and replaces it with a data property:

let lea = {
	name: "Lea Verou",
	get id() {
		return this.name.toLowerCase().replace(/\W+/g, "-");
	},
	set id(v) {
		delete this.id;
		return this.id = v;
	}
}

Abstracting the pattern into a helper

If we find ourselves needing this pattern in more than one places in our codebase, we could abstract it into a helper:

function writableGetter(o, property, getter, options = {}) {
	Object.defineProperty(o, property, {
		get: getter,
		set (v) {
			delete this[property];
			return this[property] = v;
		},
		enumerable: true,
		configurable: true,
		...options
	});
}

Note that we used Object.defineProperty() here instead of the succinct get/set syntax. Not only is the former more convenient for augmenting pre-existing objects, but also it allows us to customize enumerability, while the latter just defaults to enumerable: true.

We’d use the helper like this:

let lea = {name: "Lea Verou"};
writableGetter(lea, "id", function() {
	return this.name.toLowerCase().replace(/\W+/g, "-");
}, {enumerable: false});

Overwriting the getter with a different getter

This works when we want to overwrite with a static value, but what if we want to overwrite with a different getter? For example, consider the date use case: what if we want to maintain a single source of truth for the date components and only overwrite the format, as a function, so that when the date components change, the formatted date updates accordingly?

If we are confident that setting the property to an actual function value wouldn’t make sense, we could handle that case specially, and create a new getter instead of a data property:

function writableGetter(o, property, getter, options = {}) {
	return Object.defineProperty(o, property, {
		get () {
			return getter.call(this);
		},
		set (v) {
			if (typeof v === "function") {
				getter = v;
			}
			else {
				delete this[property];
				return this[property] = v;
			}
		},
		enumerable: true,
		configurable: true,
		...options
	});
}

Do note that if we set the property to a static value, and try to set it to a function after that, it will just be a data property that creates a function, since we’ve deleted the accessor that handled functions specially. If that is a significant concern, we can maintain the accessor and just update the getter:

function writableGetter(o, property, getter, options = {}) {
	return Object.defineProperty(o, property, {
		get () {
			return getter.call(this);
		},
		set (v) {
			if (typeof v === "function") {
				getter = v;
			}
			else {
				getter = () => v;
			}
		},
		enumerable: true,
		configurable: true,
		...options
	});
}

Improving the DX of our helper

While this was the most straightforward way to define a helper, it doesn’t feel very natural to use. Our object definition is now scattered in multiple places, and readability is poor. This is often the case when we start implementing before designing a UI. In this case, writing the helper is the implementation, and its calling code is effectively the UI.

It’s always a good practice to start designing functions by writing a call to that function, as if a tireless elf working for us had already written the implementation of our dreams.

So how would we prefer to write our object? I’d actually prefer to use the more readable get() syntax, and have everything in one place, then somehow convert that getter to a writable getter. Something like this:

let lea = {
	name: "Lea Verou",
	get id() {
		return this.name.toLowerCase().replace(/\W+/g, "-");
	}
}
makeGetterWritable(lea, "id", {enumerable: true});

Can we implement something like this? Of course. This is JS, we can do anything!

The main idea is that we read back the descriptor our get syntax created, fiddle with it, then stuff it back in as a new property:

function makeGetterWritable(o, property, options) {
	let d = Object.getOwnPropertyDescriptor(o, property);
	let getter = d.get;

	d.get = function() {
		return getter.call(this);
	};

	d.set = function(v) {
		if (typeof v === "function") {
			getter = v;
		}
		else {
			delete this[property];
			return this[property] = v;
		}
	};

	// Apply any overrides, e.g. enumerable
	Object.assign(d, options);

	// Redefine the property with the new descriptor
	Object.defineProperty(o, property, d)
}

Other mixed data-accessor properties

While JS is very firm in its distinction of accessor properties and data properties, the reality is that we often need to combine the two in different ways, and conceptually it’s more of a data-accessor spectrum than two distinct categories. Here are a few more examples where the boundary between data property and accessor property is somewhat …murky:

Note: Please don’t actually implement id/slug generation with name.toLowerCase().replace(/\W+/g, "-"). That’s very simplistic, to keep examples short. It privileges English/ASCII over other languages and writing systems, and thus, should be avoided.