🛟 Get type safety with JS descriptors

UIScript DevBlog, Part 19

Bence Meszaros
5 min readNov 11, 2024
Excellent choice, sir! Right away, sir!

Last week was pretty rough.

By the end of the week, I needed a break, so a couple of friends and I hit up a local bar for some craft beers.

They had around 30 different beers on tap, all listed beautifully: name, type, ABV, everything, even the city of origin.

One of my friends got the menu first and after a few seconds he started laughing. When I got mine I understood why: almost half of the beers came from a city called “undefined”.

I guess you never really escape JavaScript, do you?

The “beauty” of dynamic languages

We’ve all been there: you build a beautiful user interface, pay extra attention to every single detail and consider every single edge case — only to find undefined, NaN, [object Object] and similar artifacts popping up all over the place.

Pretty annoying, right?

Well, this is the price we pay for having a dynamic language: it keeps reconciling everything under the hood, instead of throwing a single error.

For small projects it’s not a big deal. But if your project grows you want certain checkpoints to guarantee that data beyond those points is always correct.

So, how do we do that?

Runtime persistence

The obvious idea would be to just use TypeScript, but it doesn’t really help in validating data loaded from the server, which is my biggest concern.

Another idea would be a validation library, but those checks aren’t persistent either, some code might alter the data after validation and I am back to square one.

I wanted something persistent, right inside vanilla JS.

Property descriptors

The best thing I could come up with is to use accessor descriptors. This is a neat little feature in JS to process a property value right before getting or setting it.

A basic example is this:

let person = {
get name() {},
set name(value) {},
};

So, to achieve type safety all we have to do is put our type (and even value) checks right inside the setter method, and we are good to go:

let person = {
get name() {},
set name(value) {
if (typeof value !== "string") throw new TypeError(`Property 'name' expects string, received ${typeof value}`);
if (value === "") throw new TypeError(`Property 'name' cannot be empty`);
},
};

Of course, if we use a getter/setter pair for a property then we also need to store the actual value somewhere, so the final example is this:

let name;
let person = {
get name() {
return name;
},
set name(value) {
if (typeof value !== "string") throw new TypeError(`Property 'name' expects string, received ${typeof value}`);
if (value === "") throw new TypeError(`Property 'name' cannot be empty`);
name = value;
},
};

It isn’t pretty, but from this point on, this property is type safe:

Person.name = 42; //TypeError: Property 'name' expects string, received number
Person.name = ""; //TypeError: Property 'name' cannot be empty

Going one step further

A property descriptor can do even more, but those options are only available if we create the property like this:

let person = Object.defineProperties({}, {
name: {
get() {},
set(value) {},
enumerable: true, //defaults to false
configurable: false //defaults to false
}
});

Note: Pay attention to the slight difference between get name()/set name() in object literals and get()/set() in property descriptors.

If the property is not enumerable, it won’t show up in loops, Object.keys(), Object.entries(), or when encoded into JSON. However, it doesn’t affect direct access, so in that regard business as usual.

If the property is not configurable we cannot change its descriptor anymore, so we can’t change get(), set(), enumerable and configurable and we can’t even delete the property. It doesn’t affect direct access either.

One additional cool feature is that if we skip the set() method (or always throw inside), the property essentially becomes read only.

let name = "Tom";

let person = Object.defineProperties({}, {
name: {
get() {
return name;
},
set(value) {
throw new TypeError(`Trying to set a read only property`);
},
enumerable: true, //defaults to false
configurable: false //defaults to false
}
});

person.name = "Bob"; //TypeError: Trying to set a read only property

A quick recap

This is a list of what we’ve achieved so far:

  1. we have an object property that throws on invalid types and values
  2. these checks are persistent throughout the application
  3. the property cannot be deleted
  4. the property can be set to be read only
  5. we can hide the property from enumerations, if needed
  6. aside from these, the property looks and works like any other property

Bonus point is that, so far, we don’t even have any dependencies.

Further examples

Using get()/set() is a powerful, yet very simple tool to achieve a wide range of automatic validations:

  • we can check min and max values for numbers and dates
  • we can check min and max lengths for strings and arrays
  • we can validate a format using regex
  • we can disallow specific values like the empty string or NaN
  • we can disallow strings with specific characters
  • and so on…

For example, creating a property for a color object might look something like this:

let red;
let color = Object.defineProperty({}, "red", {
get() {
return red;
},
set(value) {
if (typeof value !== "number") throw new TypeError(`Property 'red' expects number, received ${typeof value}`);
if (Number.isNaN(value)) throw new TypeError(`Value for 'red' cannot be NaN`);
if (value > 255) throw new TypeError(`Value for 'red' is bigger than max`);
if (value < 0) throw new TypeError(`Value for 'red' is smaller than min`);
red = value;
},
enumerable: true,
configurable: false
});

Outro

Using JS getters/setters is basically our only option and the only construct where we can achieve persistent and automatic type checks during runtime.

We achieve this by relying on get()/set() to handle our data validation automatically and behind the scenes.

It’s a pity, that Airbnb doesn’t recommend using JS getters/setters, but I can understand it from a practical point of view: without a proper abstraction, this approach can become messy in the long term.

Fortunately, this abstraction is exactly what I am planning to show you next week.

So stay tuned, follow my account and keep coding.

⬅️ Part 18 — bUt WhAtAbOuT sEmAnTiCs?

--

--

Bence Meszaros
Bence Meszaros

Written by Bence Meszaros

Lead Software Engineer, Fillun & Decketts

Responses (1)