Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Generate readonly arrays and corresponding union types for enum types #864

Closed
kamilogorek opened this issue Jan 7, 2025 · 4 comments · Fixed by #901
Closed

Generate readonly arrays and corresponding union types for enum types #864

kamilogorek opened this issue Jan 7, 2025 · 4 comments · Fixed by #901

Comments

@kamilogorek
Copy link
Member

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()
@toBeOfUse
Copy link

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?

@toBeOfUse
Copy link

toBeOfUse commented Feb 18, 2025

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
Copy link
Contributor

gwax commented Mar 3, 2025

@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
Copy link
Contributor

gwax commented Mar 14, 2025

@kamilogorek , how about #901 ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants