typed_enums

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.

What It Does

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.

What It Does Not Do

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.

Installation

Add the gem:

# Gemfile
gem "typed_enums"

Install and generate the initializer:

bundle install
bin/rails generate typed_enums:install
bin/rails typed_enums:generate

Rails Setup

Given a Rails model:

class Task < ApplicationRecord
  enum :work_priority, {
    priority_1: 0,
    priority_2: 1,
    priority_3: 2,
    priority_4: 3
  }
end

The generated JavaScript can be imported from the generated module:

import { Task } from "@/lib/enums";

Task.workPriorities;

Generated JavaScript

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];

Naming

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.

Type Usage

import { Task, type TaskWorkPriority } from "@/lib/enums";

function setPriority(priority: TaskWorkPriority) {
  return priority;
}

React

import { Task, type TaskWorkPriority } from "@/lib/enums";

export function PrioritySelect(props: {
  value: TaskWorkPriority;
  onChange: (value: TaskWorkPriority) => void;
}) {
  return (
    <select value={props.value} onChange={(event) => props.onChange(event.target.value as TaskWorkPriority)}>
      {Task.workPriorities.map((value) => (
        <option key={value} value={value}>
          {value}
        </option>
      ))}
    </select>
  );
}

Vue

<script setup lang="ts">
import { Task, type TaskWorkPriority } from "@/lib/enums";

const model = defineModel<TaskWorkPriority>();
</script>

<template>
  <select v-model="model">
    <option v-for="value in Task.workPriorities" :key="value" :value="value">
      {{ value }}
    </option>
  </select>
</template>

Svelte Or Plain TypeScript

import { Task, type TaskWorkPriority } from "@/lib/enums";

export const priorityOptions = Task.workPriorities.map((value: TaskWorkPriority) => ({
  value,
  label: value,
}));

Plain JavaScript

import { Task } from "@/lib/enums";

export const priorityOptions = Task.workPriorities.map((value) => ({
  value,
  label: value,
}));

i18n Labels

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"
      }
    }
  }
}

Configuration

The installer creates config/initializers/typed_enums.rb:

# frozen_string_literal: true

TypedEnums.configure do |config|
  config.output_dir = "app/javascript/lib"
  config.root_model_class = "ApplicationRecord"
  config.auto_generate_in_development = true
  config.watch_models_in_development = true
end

Keep the output directory isolated and import generated code from that directory. The generator never appends to user-owned frontend files.

Rake Tasks

Generate files:

bin/rails typed_enums:generate

Check files in CI:

bin/rails typed_enums:check

The 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:watch

This 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.

Development Auto-Regeneration

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.

Stale File Cleanup

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

Namespaced models are flattened into safe JavaScript identifiers:

Admin::Task

Generates exports inside:

app/javascript/lib/enums.js
app/javascript/lib/enums.d.ts
export const AdminTask = {};

Empty Output

If no models define enums, typed_enums still writes enums.js and enums.d.ts files with the generated header and no exports.

Troubleshooting

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.

Security

Enum values are written to frontend-visible JavaScript files. Do not put secrets, credentials, private tokens, or private application state in enum values.

Compatibility

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.

Contributing

Run:

bundle exec rspec
bundle exec rubocop

Keep the gem small and focused: Active Record enums in, JavaScript constants and TypeScript declaration types out.

License

MIT.