Immutable Records as object factories

Posted on October 24th, 2019

Objects in JavaScript are quite flexible. This means, they can be altered in many ways, sometimes accidentally. What to do, when you need to guarantee the consistency?

Problem with bare objects

Regular objects are pretty simple, they look like this:

const myCategory = {
  title: "Hello",
  slug: "hello",
}

console.log(myCategory);
// { title: "Hello", slug: "hello" }

Unfortunately, nothing really stops us from deleting something from it:

delete myCategory.slug;

console.log(myCategory);
// { title: "Hello" }

What if our other code relies on this property? What if the template looks like this:

<a href={`/categories/${myCategory.slug}`}>{myCategory.title}</a>

It will be broken, best case – tests will fail. Worse – rendering will break and user won't be able to use the app.

Mitigating missing properties in objects

There are some ways to fix such issues. For starters, we may refrain ourselves from deleting properties. But seriously, we can have a default value in our template:

<a href={`/categories/${myCategory.slug || "#"}`}>{myCategory.title}</a>

This helps only a bit. The link will be there, HTML will be valid, but the app is still useless. We could have a default category instead of #, but this is even worse – link "Recipes" would lead to "/categories/default".

Another way is to generate a slug when needed:

<a href={`/categories/${myCategory.slug || slugify(myCategory.title)}`}>{myCategory.title}</a>

This works well, but it is on-demand. We have to remember to add this condition everywhere. On the post listing template, on the post template, on category listing, footer etc. It is very cumbersome.

Immutable Record to the rescue

Immutable JS is one of my favorite libraries out there. A bit scary at the beginning, after getting use to it, it makes development of data structures a breeze.

One of the best features of Immutable JS is Record. Record is simply a Map with guaranteed keys.

Guaranteed how? Well, they won't magically appear – we have to provide them, but only once. They are the default values of our Record. So, let's use Record to mitigate the missing slug problem!

Okay, let's start with an interface for our input:

interface ISimpleCategory {
  title: string;
  slug?: string;
}

We've declared slug as optional. But we want our Record to have it always, so let's extend it:

interface ISimpleCategoryRecord extends ISimpleCategory {
  slug: string;
}

Okay, so interfaces are defined, great. Now the implementation:

import { Record } from "immutable";

const slugify = (input: string): string =>
  input.replace(/ /g, "-").toLowerCase();

const CategoryFactory = (
  input: ISimpleCategory
): Record<ISimpleCategoryRecord> => {
  return Record<ISimpleCategoryRecord>({
    title: input.title,
    slug: input.slug || slugify(input.title)
  })(input);
};

const catA = CategoryFactory({ title: "Testing here" });
console.log(catA.get("slug")); // "testing-here"

Let's go through it, step by step.

First, we've imported Record from immutable, as this is the only vendor we'll use.

Next, we have created some util function to replace every space with a small dash (-) and to make the string lowercase. This is a super-basic slug implementation.

Now, we have created a CategoryFactory function that receives a single parameter – input with the interface ISimpleCategory. Inside this function, we simply returned a Record with interface ISimpleCategoryRecord, that has slug as mandatory. Now, whenever using an entity created with this factory, we will receive typehints – here, about what fields are available for us.

I know this isn't a very clean function, since it relies on an external vendor, but this is for demonstration purposes only.

The most interesting here is the body of our Record. Please note the slug field. It either takes our input's slug, or creates its own with slugify. This way we always know, that we'll get both title and slug, as long as we will provide the former.

Bonus part: Factory without an external dependency

Like I've said earlier, usage of slugify is purely for demonstration. But I wouldn't be myself, if I left it that way. So let's create a variant that can have slugify passed as a parameter. It can be called a simple dependency injection, great for testing, for example. Looks like this:

function CategoryFactoryWithDepsInjectConstructor(
  slugify: (inp: string) => string
) {
  return function createRecord(input: ISimpleCategory) {
    return Record<ISimpleCategoryRecord>({
      title: input.title,
      slug: input.slug || slugify(input.title)
    })(input);
  };
}

Let's go through it real fast. First thing – function notation instead of const have more clarity. It looks cleaner and more concise. Next thing is, first parameter is not our input with ISimpleCategory interface, but a slugify function. Why? Because we're using currying here, so we will have a function returning a function. Only then we created createRecord, that is our actual factory. The rest is as it was.

Okay, so how do we run it? Actually very simple, but we don't want to inject slugify every time we use this. This is counter-productive, and I am a lazy person. So, let's create an instance of this function with this vendor bound:

const CategoryFactoryWithDepsInject = CategoryFactoryWithDepsInjectConstructor(
  slugify
);

How is this different from the one we've used previously? Well, CategoryFactoryWithDepsInject is not, but CategoryFactoryWithDepsInjectConstructor differs greatly. And we can use both! So, for example, we'll get the former in our normal production codebase, but the latter in testing, and inject something else instead of our "utility" function. Or, we could create a more complex variant with different variant of slugify injected.

But, frankly, this is beyond the point.

Conclusion

Working with data in JavaScript is not always easy. Missing properties are a pain and seeing undefined when querying for a value can be troublesome, to say the least. Fortunately, Records are great and can very easily help us mitigate most of the issues.

Links

(c) 2020 buszewski.com — All rights reserved. This site probably harvests your cookies.