Use Rails enums in JavaScript and TypeScript without duplicating option lists.
typed_enums is a Rails enum generator for JavaScript and TypeScript frontends. It exports Active Record enums to JavaScript constants and TypeScript declaration types, giving any frontend a Rails-like way to consume enum values.
Rails routes have js-routes. Rails serializers have serializer type generators. Rails enums now have typed_enums.
typed_enums scans loaded Active Record models, reads defined_enums, and writes one generated JavaScript module plus a TypeScript declaration file:
app/javascript/lib/enums.js
app/javascript/lib/enums.d.ts
It works with React, Vue, Svelte, API-only Rails apps, plain JavaScript, or any TypeScript frontend. The primary runtime output is plain JavaScript. No frontend framework, bundler, or TypeScript runtime package is required by the gem itself.
This gem is not a serializer, OpenAPI generator, route helper generator, form builder, or enum replacement. It does not generate React components, Vue components, Svelte components, migrations, or runtime enum editing tools.
Add the gem:
# Gemfile
gem "typed_enums"Install and generate the initializer:
bundle install
bin/rails generate typed_enums:install
bin/rails typed_enums:generateGiven a Rails model:
class Task < ApplicationRecord
enum :work_priority, {
priority_1: 0,
priority_2: 1,
priority_3: 2,
priority_4: 3
}
endThe generated JavaScript can be imported from the generated module:
import { Task } from "@/lib/enums";
Task.workPriorities;The gem writes one JavaScript module containing every model enum:
// AUTO-GENERATED BY typed_enums. DO NOT EDIT.
// enum-schema-sha256: abc123
export const Task = {
workPriorities: ["priority_1", "priority_2", "priority_3", "priority_4"],
};It also writes enums.d.ts for TypeScript users:
// AUTO-GENERATED BY typed_enums. DO NOT EDIT.
// enum-schema-sha256: abc123
export declare const Task: {
readonly workPriorities: readonly ["priority_1", "priority_2", "priority_3", "priority_4"];
};
export type TaskWorkPriority = (typeof Task.workPriorities)[number];Rails exposes enum mappings through pluralized methods, such as Task.work_priorities. typed_enums mirrors that convention in camelCase:
| Rails enum attribute | Rails mapping method | TypeScript property | TypeScript type |
|---|---|---|---|
ticket_status |
ticket_statuses |
ticketStatuses |
TaskTicketStatus |
work_priority |
work_priorities |
workPriorities |
TaskWorkPriority |
fix_priority |
fix_priorities |
fixPriorities |
TaskFixPriority |
severity |
severities |
severities |
TaskSeverity |
urgency |
urgencies |
urgencies |
TaskUrgency |
This naming is intentionally not configurable in v1.
import { Task, type TaskWorkPriority } from "@/lib/enums";
function setPriority(priority: TaskWorkPriority) {
return priority;
}import { useId, useState, type FormEvent } from "react";
import { Task, type TaskWorkPriority } from "@/lib/enums";
type TaskRecord = {
id: number;
workPriority: TaskWorkPriority;
};
type UpdateTaskPriorityParams = {
work_priority: TaskWorkPriority;
};
export function EditTaskPriority(props: {
task: TaskRecord;
onSubmit: (taskId: number, params: UpdateTaskPriorityParams) => void | Promise<void>;
}) {
const priorityFieldId = useId();
const [workPriority, setWorkPriority] = useState<TaskWorkPriority>(props.task.workPriority);
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
void props.onSubmit(props.task.id, { work_priority: workPriority });
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor={priorityFieldId}>Work priority</label>
<select
id={priorityFieldId}
name="work_priority"
value={workPriority}
onChange={(event) => setWorkPriority(event.currentTarget.value as TaskWorkPriority)}
>
{Task.workPriorities.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
<button type="submit">Save</button>
</form>
);
}import { useId, type FormEvent } from "react";
import { useForm } from "@inertiajs/react";
import { Task, type TaskWorkPriority } from "@/lib/enums";
type TaskRecord = {
id: number;
workPriority: TaskWorkPriority;
};
type UpdateTaskPriorityParams = {
work_priority: TaskWorkPriority;
};
export function EditTaskPriority(props: {
task: TaskRecord;
}) {
const priorityFieldId = useId();
const form = useForm<UpdateTaskPriorityParams>({
work_priority: props.task.workPriority,
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
form.put(`/tasks/${props.task.id}`);
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor={priorityFieldId}>Work priority</label>
<select
id={priorityFieldId}
name="work_priority"
value={form.data.work_priority}
onChange={(event) => form.setData("work_priority", event.currentTarget.value as TaskWorkPriority)}
disabled={form.processing}
>
{Task.workPriorities.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
<button type="submit" disabled={form.processing}>
Save
</button>
</form>
);
}<script setup lang="ts">
import { ref, useId } from "vue";
import { Task, type TaskWorkPriority } from "@/lib/enums";
const props = defineProps<{
task: {
id: number;
workPriority: TaskWorkPriority;
};
}>();
const emit = defineEmits<{
submit: [taskId: number, params: { work_priority: TaskWorkPriority }];
}>();
const priorityFieldId = useId();
const workPriority = ref<TaskWorkPriority>(props.task.workPriority);
</script>
<template>
<form @submit.prevent="emit('submit', props.task.id, { work_priority: workPriority })">
<label :for="priorityFieldId">Work priority</label>
<select :id="priorityFieldId" v-model="workPriority" name="work_priority">
<option v-for="value in Task.workPriorities" :key="value" :value="value">
{{ value }}
</option>
</select>
<button type="submit">Save</button>
</form>
</template><script setup lang="ts">
import { useId } from "vue";
import { useForm } from "@inertiajs/vue3";
import { Task, type TaskWorkPriority } from "@/lib/enums";
const props = defineProps<{
task: {
id: number;
workPriority: TaskWorkPriority;
};
}>();
const priorityFieldId = useId();
const form = useForm<{
work_priority: TaskWorkPriority;
}>({
work_priority: props.task.workPriority,
});
</script>
<template>
<form @submit.prevent="form.put(`/tasks/${props.task.id}`)">
<label :for="priorityFieldId">Work priority</label>
<select :id="priorityFieldId" v-model="form.work_priority" name="work_priority" :disabled="form.processing">
<option v-for="value in Task.workPriorities" :key="value" :value="value">
{{ value }}
</option>
</select>
<button type="submit" :disabled="form.processing">
Save
</button>
</form>
</template>The route strings are intentionally plain for the examples. In Rails apps that use js-routes, prefer generated route helpers instead of building URLs by hand.
import { Task, type TaskWorkPriority } from "@/lib/enums";
export const priorityOptions = Task.workPriorities.map((value: TaskWorkPriority) => ({
value,
label: value,
}));import { Task } from "@/lib/enums";
export const priorityOptions = Task.workPriorities.map((value) => ({
value,
label: value,
}));The generated values are intentionally raw enum values. Use your frontend i18n layer to label them:
import { Task } from "@/lib/enums";
Task.workPriorities.map((value) => ({
value,
label: t(`enums.task.workPriorities.${value}`),
}));Example keys:
{
"enums": {
"task": {
"workPriorities": {
"priority_1": "P1",
"priority_2": "P2"
}
}
}
}The installer creates config/initializers/typed_enums.rb:
# frozen_string_literal: true
TypedEnums.configure do |config|
# Generated JavaScript and TypeScript declaration files are written here.
config.output_dir = "app/javascript/lib"
# Automatically regenerate enum files in development.
config.auto_generate_in_development = true
# Watch app/models in development and regenerate after saves.
config.watch_models_in_development = true
endIf your enum models do not inherit from ApplicationRecord, configure config.root_model_class to the shared base class that exposes your Active Record descendants.
Keep the output directory isolated and import generated code from that directory. The generator never appends to user-owned frontend files.
Generate files:
bin/rails typed_enums:generateCheck files in CI:
bin/rails typed_enums:checkThe check task exits non-zero when files are missing, changed, or stale. It does not modify files.
Watch model files and regenerate after saves:
bin/rails typed_enums:watchThis is useful when you want generated enum files to update immediately after saving a Rails model in VS Code without relying on a running Rails server.
In development, generation runs on Rails boot and through Rails.application.reloader.to_prepare when auto_generate_in_development is enabled.
When watch_models_in_development is enabled, typed_enums also watches app/models/**/*.rb and regenerates enum files after model saves. This covers normal VS Code save workflows while the Rails development process is running. If you are not running Rails, use bin/rails typed_enums:watch in a separate terminal.
The writer compares content before writing, so unchanged files are not rewritten and file watchers should stay quiet.
Production does not auto-write files by default. Run bin/rails typed_enums:generate as part of your build or release process when generated enum files are not committed.
typed_enums only overwrites files that it created.
If enums.js or enums.d.ts already exists without this generated marker, generation fails instead of overwriting the file:
// AUTO-GENERATED BY typed_enums. DO NOT EDIT.Move the existing file, delete it, or configure a different config.output_dir.
The writer removes stale generated .js, .d.ts, and legacy .ts files inside the configured output directory only when they start with:
// AUTO-GENERATED BY typed_enums. DO NOT EDIT.It does not delete user-owned files or files outside the generated directory.
Namespaced models are flattened into safe JavaScript identifiers:
Admin::TaskGenerates exports inside:
app/javascript/lib/enums.js
app/javascript/lib/enums.d.ts
export const AdminTask = {};If no models define enums, typed_enums still writes enums.js and enums.d.ts files with the generated header and no exports.
If models are missing, confirm they can be eager-loaded in the current environment. The scanner calls Rails.application.eager_load! before reading descendants.
If the root model class is not ApplicationRecord, configure root_model_class.
If generated files cannot be written, check filesystem permissions for config.output_dir.
If check mode fails in CI, run bin/rails typed_enums:generate locally and commit the generated files, or add generation to your build before JavaScript or TypeScript compilation.
Enum values are written to frontend-visible JavaScript files. Do not put secrets, credentials, private tokens, or private application state in enum values.
The gem targets modern Rails applications and supports Rails 7.1 or newer. It uses Active Record enum APIs and ActiveSupport inflections, and it does not depend on any frontend framework. The React, Vue, and Inertia examples target current framework versions and are provided as copyable usage examples for generated enum values.
Run:
bundle exec rspec
bundle exec rubocopKeep the gem small and focused: Active Record enums in, JavaScript constants and TypeScript declaration types out.
MIT.