How to Customize the openapi-typescript Generator for Branded Types

Article summary

In Typescript, a technique known as branding is often used when you need to differentiate types that would otherwise be structurally compatible. Branding is fairly easy to use in your own code but doesn’t often work with generated code. But if you can customize a code generator to output branded types, it can pay off in reduced friction.

Branded Types

A common use of branded types is database IDs, which may be string or number, but you don’t want your User IDs to get mixed up with your Account IDs.

Typescript branding is common enough these days that you can search for it and find many implementations. But they basically come down to this:

type Branded<T, A> = A & { __brand: T };

This utility type enables other types to be branded. So continuing with our example IDs, we could define them like this:

type UserID = Branded<"User", string>;
type AccountID = Branded<"Account", string>;

Now if we define a function that accepts a UserID and you try to pass it an AccountID, typescript will raise a type error (at compile time). This is great, but it’s not without some friction.

Because a branded type is narrower than its underlying type, you can no longer make assignments using primitive types. So something like this is a type error:

// Type 'string' is not assignable to type 'UserID'
const userId: UserID = "my-user-id";

// We must resort to this
const userId: UserID = "my-user-id" as UserID;

I am always suspicious when I see a cast since it means we are overriding some TypeScript protection. It’s even more error-prone when the value comes from another variable. So the fewer places that we need these, the better.

Generated Code

It’s one thing to use branded types throughout our own code, but most code generators won’t use them. That means more places we have to “lift” primitive types into branded space. That is, unless, of course, the code generator can be customized to be aware of branded types.

Not all code generators are this flexible, but the openapi-typescript just happens to be one that is. This package conveniently generates types from an openapi definition.

If you just install the openapi-typescript package, you’ll get an openapi-typescript command that can generate typescript from an openapi YAML definition. But customizing it to work with branded types requires a bit of extra work. We’ll have to write our own script, including handling the input YAML. But fortunately that’s easy using js-yaml (and @types/js-yaml).

When used as a library, openapi-typescript provides the openapiTS function. This is where we’ll provide our customization. There are many ways you could choose to do this, but I decided to augment the “format” property of an openapi node. This means I’ll be able to include a schema definition for my ID types, referenced from other schemas, like this:

components:
  schemas:
    UserID:
      type: string
      format: brand::UserID

    User:
      type: object
      properties:
        id:
          $ref: "#/components/schemas/UserID"
        name:
          type: string

I’m using the brand:: prefix as the hint to generate a branded type. So putting it all together, my script looks like this:

import * as fs from "fs";
import yaml from "js-yaml";
import openapiTS from "openapi-typescript";
import { OpenAPI3, SchemaObject } from "openapi-typescript";

function main() {
  const yamlDoc = fs.readFileSync(process.stdin.fd, "utf-8").toString();
  const schema = yaml.load(yamlDoc) as OpenAPI3;
  const output = openapiTS(schema, {
    formatter: (node: SchemaObject) => {
      if (node.format?.startsWith("brand::")) {
        const brandType = node.type ?? "string";
        const brandName = node.format?.split("::")[1];
        return `${brandType} & { __brand: "${brandName}" }`;
      }
      return undefined;
    },
  });
  process.stdout.write(output);
}

void main();

Once compiled, this script will accept an openapi YAML definition on stdin and emit the generated code on stdout. The schema above results in generated code like this:

export interface components {
  schemas: {
    User: {
      userId: components["schemas"]["UserID"];
      name: string;
    }
    UserID: string & { __brand: "UserID" };
  }
}

Customizing the generator is a bit of an upfront investment. But with this in place, you can have a little more confidence in your branded types.