supabase/postgres-meta

Generate readonly arrays and corresponding union types for enum types

kamilogorek opened this issue · 4 comments

Currently our enums are generated like so:

export type Database = {
  public: {
    Enums: {
      api_key_type: 'FOO' | 'BAR' | 'BAZ'
    }
  } 
}

This however makes it impossible to be used in all kinds of parsers or validators, which require a value as an input, not the type, as those are stripped during compilation.

A solution to this is ti generate a type for every corresponding enum in a form of:

export const status = ['FOO', 'BAR', 'BAZ'] as const
export type status = (typeof status)[number]

and somehow expose it publicly.

Our existing `ts-morph` implementation below (click to expand)
// This script uses ts-morph to generate a "runtime enums", which are
// simply an array of all possible union types.
// The values are based on generated database enums and are required
// in order for us to be able to use those types in all kind of validators.
// It also creates additional copy of the same union type, with the same name
// and based on the array readonly values for convinience.

import { Project, SyntaxKind, VariableDeclarationKind } from 'ts-morph'

const inputFilePath = process.argv[2]
const enumsFilePath = process.argv[3]

if (!inputFilePath) {
  throw new Error(`generate-runtime-enums.ts requires input path to be specified as 1st argument`)
}

if (!enumsFilePath) {
  throw new Error(`generate-runtime-enums.ts requires output path to be specified as 2nd argument`)
}

console.log(`> Generating runtime enums from ${inputFilePath} at ${enumsFilePath}...`)

const project = new Project()
const originalFile = project.addSourceFileAtPath(inputFilePath)
const outputFile = project.createSourceFile(enumsFilePath, '', { overwrite: true })

const generatedEnums = originalFile
  .getTypeAliasOrThrow('Database')
  .getTypeNodeOrThrow()
  .getFirstDescendant(
    (node) => node.isKind(SyntaxKind.PropertySignature) && node.getName() === 'public'
  )
  ?.getFirstDescendant(
    (node) => node.isKind(SyntaxKind.PropertySignature) && node.getName() === 'Enums'
  )
  ?.getDescendantsOfKind(SyntaxKind.PropertySignature)

if (!generatedEnums) {
  throw new Error(
    `No enums found, this should never happen; Tell Kamil he messed up and should fix it.`
  )
}

for (const enumProp of generatedEnums) {
  const name = enumProp.getName()
  const values = enumProp
    .getTypeNodeOrThrow()
    .getType()
    .getUnionTypes()
    .map((type) => type.getLiteralValue())
    .filter((value) => typeof value === 'string')

  outputFile.addVariableStatement({
    declarationKind: VariableDeclarationKind.Const,
    declarations: [
      {
        name,
        initializer: `[${values.map((value) => `'${value}'`).join(', ')}] as const`,
      },
    ],
    isExported: true,
  })

  outputFile.addTypeAlias({
    name,
    type: `(typeof ${name})[number]`,
    isExported: true,
  })
}

outputFile.saveSync()

This is very useful. Is there a reason to use this over Typescript enums, which combine the functionality of the runtime variables and the compile-time types?

The Typescript string enum output implementation would look like this:

  outputFile.addEnum({
    name: name + '_enum',
    members: values.map((value) => ({
      name: value,
      value,
    })),
    isExported: true,
  });

To produce output like this:

export enum product_approval_status_enum {
    "action-needed" = "action-needed",
    disputed = "disputed",
    pending = "pending",
    approved = "approved"
}

You can use the enum as a type:

let status: product_approval_status_enum | null = null;
// ...
status = product_approval_status_enum.approved;

Or you can use Object.values to retrieve the strings that represent the values that it can have at runtime:

const approval_statuses = Object.values(product_approval_status_enum);
//...
const type_checked_value: product_approval_status_enum = approval_statuses[0];
gwax commented

@toBeOfUse , The downside to Typescript Enums is that they cannot be defined inline, which prevents constructing a hierarchical structure. Additionally, postgresql enum values can have characters that aren't valid for Typescript enum keys.

gwax commented

@kamilogorek , how about #901 ?