Build a Lightweight Code Generator with TypeScript and JSON Imports

On a recent software development project, my team put together a lightweight code generator with reusable techniques that I want to share. Read on for the why and the how.

Using a Code Generator

You might want a code generator if:

  • Your software interfaces with an external system.
  • Your software depends on the structure of information defined in that external system.

You might really want a code generator if:

  • That structure is likely to change during the lifetime of your software.
  • You don’t have another good way of adapting to these changes over time.

We met these criteria with a common situation: using a third-party service to send email.

The Context

This project uses Postmark to send emails. You create email templates in the Postmark web interface:

As you’re editing an email template, you can reference variables, and values are expected to be supplied by the function call that sends emails.

In the example code above, valid values for TemplateAlias and TemplateModel depend entirely on edits made in the PostMark dashboard. So, how can we let our TypeScript application code know what can go here?

The Approach

While using the Postmark.js library, we noticed it has methods for finding the available email templates. We could retrieve the templates from Postmark, save them to disk, and build them into the app!

But save them as what? It wouldn’t be hard to write out some very simple TypeScript files, but I found something even easier: raw JSON. More on this later.

The Code

Here’s a command-line script (more on tsx here) that connects to PostMark, retrieves email templates, and writes them to a .JSON file on disk.


#!/usr/bin/env tsx
import fs from "fs/promises";
import { ServerClient } from "postmark";

async function go() {
  if (process.argv.length != 3) {
    console.log(`Usage: pnpm tsx retrieve-email-templates.ts output.json`);
    process.exit(0);
  }
  const output_file = process.argv[2];

  const token = process.env.POSTMARK_TOKEN;
  if (!token) {
    console.log("Please supply token via $POSTMARK_TOKEN");
    process.exit(1);
  }
  const client = new ServerClient(token);
  const templates = await client.getTemplates({});
  console.log(`Retrieved ${templates.TotalCount} templates:`);

  const aliases = templates.Templates.map((t) => t.Alias).filter(isNotNull);
  aliases.forEach((a) => console.log(`  ${a}`));
  const obj = await collectTemplates(client, aliases);
  await fs.writeFile(output_file, JSON.stringify(obj, undefined, 2));
  console.log(`nWrote ${output_file}`);
}
type TemplateCollection = Record<string, object>;
async function collectTemplates(
  client: ServerClient,
  templateNames: string[],
): Promise<TemplateCollection> {
  const [name, ...tail] = templateNames;
  const model = (await modelForTemplate(client, name)) || {};

  const remaining =
    tail.length == 0 ? {} : await collectTemplates(client, tail);
  return { [name]: model, ...remaining };
}

async function modelForTemplate(client: ServerClient, idOrAlias: string) {
  /*
  There isn't a great way to ask Postmark, "what is the model for this template",
  but we can get it from the _SuggestedTemplateModel_ that comes back from validateTemplate.
  */
  const example = await client.getTemplate(idOrAlias);
  const validated = await client.validateTemplate({
    HtmlBody: example.HtmlBody || "",
    Subject: example.Subject || "",
  });
  return validated.SuggestedTemplateModel;
}

export function isNotNull<T>(input: T | null): input is T {
  return input !== null;
}

go();

export {};

This produces output like this:


{
  "user-invitation-1": {
    "name": "name_Value",
    "invite_sender_name": "invite_sender_name_Value",
    "invite_sender_organization_name": "invite_sender_organization_name_Value",
    "product_name": "product_name_Value",
    "action_url": "action_url_Value",
    "support_email": "support_email_Value",
    "live_chat_url": "live_chat_url_Value",
    "help_url": "help_url_Value"
  },
  "another-email-template":{
    "foo": "foo_Value"
    /* ... */
  }
}

Here’s the cool part: when TypeScript imports JSON, it knows the structure1. We import the JSON directly into TypeScript, derive types from it, and write a generic function:


import templateJson from "./postmark-templates.gen.json";

export type PostmarkTemplates = typeof templateJson;
export type TemplateName = keyof typeof templateJson;

export async function sendEmailTemplate<T extends TemplateName>(
  templateName: T,
  templateFields: PostmarkTemplates[T],
  recipient: string,
) {
  // todo: send email
}

Now we can call sendEmailTemplate with completions based on the template structure from Postmark:

This helped with writing email-sending code correctly the first time. And, more importantly, it helps keep the application code accurate over time. When edits are made to the templates in Postmark, we can run the generator, compile the app, and follow the squiggles!

Custom Code Generator

We’ve long been fans of code generators for problems like this. But, as with many tech decisions, we hem and haw over whether the value provided will recoup the upfront development cost. It’s an easy decision when the cost is low due to standards and tooling (like OpenAPI or GraphQL). However, in my mind, a “custom code generator” was a big hammer reserved for big problems. This project changed my expectations, lowering the bar significantly, and I expect to use the technique again. I hope it can save you some time, too!


Footnotes:
1: I don’t expect that this is universally true across the wild-west ecosystem of frameworks and compilers, but it worked for this project with Next.js and TypeScript 5.

Conversation

Join the conversation

Your email address will not be published. Required fields are marked *