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];@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.
@kamilogorek , how about #901 ?