Refactoring Compas code-gen: part 2

Refactoring Compas code-gen: part 2

Combining generator entrypoints

Recently we started working on refactoring the code generators of Compas.js. Compas.js is a framework based on a structure providing generated routers, validators, typed api clients and more.

Current situation

Compas.js has a main entrypoint provided by the @compas/code-gen package: App. The idea behind that name was that you would define your whole application structure through that single App instance. The App instance then exposes three methods to generate some output, as mentioned in Part 1 of this series:

  • App#generate: main entrypoint for generating
  • App#generateTypes: generate call to combine types of various other generate calls
  • App#generateOpenApi: convert Compas.js structure to an OpenAPI 3.x compatible specification


Our redesign still has a single entrypoint, but now called Generator instead of App. We experienced that most of the time you need more than a single generator, for example when importing the OpenAPI spec of a service that you are using. This entrypoint will also have a single generate call Generator#generate. It combines the options and usecases of the previous three generate calls in to a single method, combining and redesigning the accepted options, which are discussed below.

Using the Generator will look something like this (minor changes could still be done):

import { Generator } from "@compas/code-gen/experimental";

// The TypeCreators are backwards compatible
import { TypeCreator } from "@compas/code-gen";

const appGenerator = new Generator();

appGenerator.add(/* ... */);

  outputDirectory: "./src/generated",
  generators: {
    structure: {},
    router: {
      target: { library: "koa" },

const publicGenerator = appGenerator.selectGroups("public");
const publicFiles = publicGenerator.generate({
  generators: {
    openApi: {},

// Do something with the public files

Improving targets

The current situation to define targets accepted by App#generate looks something like:

type GenerateOptions = {
  isBrowser?: boolean;
  isNode?: boolean;
  isNodeServer?: boolean;
  useTypescript?: boolean;

As mentioned earlier, I personally have no clue what the difference is between isNode and isNodeServer. Other problems with the current setup are that isBrowser currently should be used for both browser as well as react-native targets. Adding a new option to for example add support for generating Go would result in a new useGolang option.

In the new setup we use a top-level targetLanguage option. The output of a single generate call could add imports between its generated files, so these should be using the same language. For generator specific targets, like the generate api clients, we accept a nested target

type GenerateOptions = {
  targetLanguage: "js" | "ts";
  generators: {
    apiClient: {
      target: {
        library: "axios";
        targetRuntime: "node.js" | "browser" | "react-native";

This new target definition for in this case api client ensure that we could add support for Fetch, Undici or a completely different client for a different targetLanguage without having unrelated options top level affecting the output like isNodeServer would.

A drawback here is that the targetLanguage and the nested target definitions have some relation between them. This could lead to a degraded developer experience. It is then more important than ever to provide clear errors when the targetLanguage and target are not compatible, nudging the user in either a different target or a different targetLanguage.


The flexibility of the new options give a lot of oppurtinity to directly support new targets like the aforementioned Fetch or Undici based api clients. However, we limit the scope of this refactor to only support the current outputs:

  • JavaScript or Typescript output
  • Axios based api clients
  • Koa based router
  • And PostgreSQL based SQL

For the all supported options see the initial refactor issue on GitHub or the TypeScript definition on the experimental branch