Tom MacWright

tom@macwright.com

Development notes

So in the course of working on Placemark, I’m solving a lot of small problems that are each too small and niche to merit their own blog post. But I hate letting things go unwritten, so here they are, smorgasbord style.

Getting Tailwind and Radix to play nice together

I’ve been using Tailwind for Placemark’s CSS foundation, and Radix for many of its UI components. This mostly works great, because Radix makes it easy to style your own components.

However, there’s a catch: a lot of important UI states for Radix are distinguished by aria accessibility properties or HTML data- attributes. For example, when the trigger for a dropdown menu is engaged, Radix adds data-state="open" to the element. This is all fine and dandy if you’re writing new CSS classes, or using a CSS-in-JS solution, but I’m doing neither: Placemark just uses old-fashioned classes. And Tailwind doesn’t give you a class that changes the color of a button when the data-state attribute changes.

The solution isn’t too bad, but it was a little tricky to track down:

const plugin = require("tailwindcss/plugin");

module.exports = {
  // ...
  // In tailwind.config.js
  plugins: [
    plugin(function ({ addVariant }) {
      addVariant("aria-expanded", "&[aria-expanded='true']");
      addVariant("data-state-on", '&[data-state="on"]');
    }),
  ]
}

So you can create a “plugin” that just uses addVariant to add another selector to Tailwind’s knowledge. So now I can add aria-expanded:bg-purple-500, just like I’d add a hover: or dark: variant.

Using Maybe and Either types

There are certain parts of Placemark, like file format converters, that have very detailed success & error types. For example, a file import might fail because of a syntax error in a JSON or XML file, and we want to report that. Or an import might partly succeed with notes, and the notes should be displayed.

There are a lot of ways you could implement this. Just off the top of my head:

  • Returning either the result or an Error type from each function and using instanceof to check which was produced. The downside is that you might forget to check which type you have and access the .message property of an Error when you were looking for the message property of a success object.
  • Throwing errors and catching them. The downside is that TypeScript doesn’t include the types of errors in its type system and all errors that you catch in try/catch are unknown, so you have to do some sketchy type reassignment.
  • Creating a discriminated union to return plain objects with error or success type properties.
  • Using an implementation of the Either type.

I floated from the first solution to the last, after discovering purify, a very servicable module for these types. There’s lots of theory behind the Either type, but in essence it’s a container that might have a failure or a success value, and makes it very hard to accidentally deal with one when you mean the other.

So now, for example, a conversion method in Placemark might have this type signature, which neatly types both the error and successful case:

forwardBinary?: (
  file: File,
  options: ImportOptions
) => EitherAsync<Error | PlacemarkError, ConvertResult>;

This abstraction isn’t free, though. Here are some things that came up.

My tests that deal with these Either values got annoying. If I had

expect(fooMethod()).toEqual(10);

I’d end up with this, after changing fooMethod to return an Either object:

expect(fooMethod().unsafeCoerce()).toEqual(10);

This is not great. unsafeCoerce will throw an ugly exception of fooMethod’s result is not success, and it’s cluttering my tests. Hence, I wrote some test helpers for Jest + Purify-ts that might be of help to other folks: they let me write this test like

expect(fooMethod()).toEqualRight(10);

And if fooMethod fails, the test produces a readable report. My short time in the land of Ruby on Rails spoiled me for tests - with gems like shoulda and even with rspec’s core assertions you could get really nice test reports and succinct tests by keeping boilerplate out of your testing code. From then on, I decided to try and get the same experience in other languages.

The other thing is serialization. For a lot of methods, I could serialize their return values - for example, to send them to a WebWorker. I can’t do that with Either values. That said, thankfully I’m using Comlink to abstract around WebWorkers, and it has a a spectacularly cool system of transfer handlers that let you manually serialize & deserialize certain values. Hence, a purify-ts/Comlink transfer handler lets my WebWorkers seamlessly receive & return Either values.

UUIDs

I recently transitioned Placemark’s IDs for features to UUIDs - specifically, UUIDv1. This was a weird and tough decision, and I collected some assorted resources for anyone else going down the same rabbit hole:

Frankly, this decision was premature optimization. I haven’t experienced index bloat in my database, but I’ve read of it enough to fear it. Also, I was switching away from nanoid, a lovely module but one that produces 21-character IDs that aren’t compatible with the UUID format. Braindump thoughts below:

  • The Postgres UUID type has very efficient internal storage, and kludgy long (36 or 32-character) string encodings.
  • So, nanoid IDs are shorter when you’re dealing with them as strings, but longer, and less efficient, when they’re stored in the database or part of an index.
  • The UUIDv1 specification has some rough edges - it technically encodes a MAC address in each ID. It doesn’t practically do that, though. The uuid module just uses a random id instead.
  • Updating to UUIDv7 (or any other UUID version) in the future should be seamless. Though you can technically extract and validate data in a UUID, practically there’s no reason to. If you can represent it as a 32-character hex string, Postgres will store it as a UUID.
  • The best of both worlds would be UUID storage in the database, sequential values with the proposed draft v7, and a short encoding like compact-uuids. For now, I’m not spending time on a short encoding, which means I’m paying about 6 extra bytes of storage wherever a UUID is, in the JavaScript context.
  • I’m using UUIDs because it’s core to the multiplayer + offline-capable paradigm, but my thinking about them has stayed the same: if you have a simpler, non-realtime-collaborative, non-distributed system, just use those beautiful, simple autoincrementing ints.

Finding dependencies

A quick note on finding libraries - npm modules - for your project.

There are a lot of npm modules. If you have a need, like input validation or color parsing or whatever, a raw GitHub search for what you want will probably bring up some junk. It can be hard to tell which modules are good and which are bad. There’s a startup aiming to solve this problem, but until they do, here are some things I think about:

  • Pick modules from the ones you already have. If you’re working on a project that’s based on Next or express or any other high-level framework, that framework itself has dependencies, and they’re probably good ones. So crack open your yarn.lock or package-lock.json file, read it - it’s long, but readable - and see if you already have a solution at hand.
  • Know the people. Through experience I can know that modules made by Sindre Sorhus or Titus or Luke Edwards will be quality. The same goes for a long list of people whose code I’ve relied on. So, when you’re looking through things, pay attention to the people. An informal trust component can really clarify decisions.