Generate Zod schemas from ActiveRecord models
Bridge the gap between your Rails backend and TypeScript frontend with type-safe validation.
Generate Zod schemas from your ActiveRecord models. Bridge the gap between your Rails backend and TypeScript frontend with type-safe validation that stays in sync with your database schema and model validations.
When building Rails APIs consumed by TypeScript frontends, you often duplicate validation logic: once in your Rails models, again in your frontend forms. ZodRails eliminates this duplication by generating Zod schemas directly from your ActiveRecord models, including:
- Database column types mapped to Zod types
- Rails validations (presence, length, numericality, format) mapped to Zod constraints
- Enums generated as
z.enum()with proper string literals - Nullable columns and default values handled correctly
- Separate schemas for API responses vs. form inputs
- Ruby 3.2+
- Rails 7.0+ (uses ActiveRecord and Railtie)
- Zod 4.x in your frontend project
Add to your Gemfile:
gem "zod_rails"Then run:
bundle installCreate an initializer at config/initializers/zod_rails.rb:
ZodRails.configure do |config|
config.output_dir = Rails.root.join("app/javascript/schemas").to_s
config.models = %w[User Post Comment]
endbin/rails zod_rails:generateimport { UserSchema, UserInputSchema, type User } from "./schemas/user";
// Validate API response
const user = UserSchema.parse(apiResponse);
// Validate form input
const formData = UserInputSchema.parse(formValues);| Option | Default | Description |
|---|---|---|
output_dir |
app/javascript/schemas |
Directory for generated TypeScript files |
models |
[] |
Array of model names to generate schemas for |
schema_suffix |
Schema |
Suffix for response schemas (e.g., UserSchema) |
input_schema_suffix |
InputSchema |
Suffix for input schemas (e.g., UserInputSchema) |
generate_input_schemas |
true |
Whether to generate input schemas |
excluded_columns |
["id", "created_at", "updated_at"] |
Columns to exclude from input schemas |
ZodRails.configure do |config|
config.output_dir = Rails.root.join("frontend/src/schemas").to_s
config.models = %w[User Post Comment Tag]
config.schema_suffix = "Schema"
config.input_schema_suffix = "FormSchema"
config.generate_input_schemas = true
config.excluded_columns = %w[id created_at updated_at deleted_at]
end| Rails/DB Type | Zod Type |
|---|---|
string, text |
z.string() |
integer |
z.int() |
float |
z.number() |
bigint |
z.string() (avoids JS Number overflow) |
decimal |
z.string() (preserves BigDecimal precision) |
boolean |
z.boolean() |
date |
z.iso.date() |
datetime, timestamp |
z.iso.datetime() |
json, jsonb |
z.json() |
uuid |
z.uuid() |
time |
z.string() |
binary |
z.string() |
enum |
z.enum([...]) |
ZodRails introspects your model validations and maps them to Zod constraints:
| Rails Validation | Zod Constraint |
|---|---|
presence: true |
.min(1) for string/text columns |
length: { minimum: n } |
.min(n) |
length: { maximum: n } |
.max(n) |
length: { is: n } |
.length(n) |
numericality: { greater_than: n } |
.gt(n) |
numericality: { greater_than_or_equal_to: n } |
.gte(n) |
numericality: { less_than: n } |
.lt(n) |
numericality: { less_than_or_equal_to: n } |
.lte(n) |
format: { with: /regex/ } |
.regex(/regex/) |
inclusion: { in: n..m } |
.min(n).max(m) |
Given this Rails model:
class User < ApplicationRecord
enum :role, { member: 0, admin: 1, moderator: 2 }
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :name, presence: true, length: { minimum: 2, maximum: 100 }
validates :age, numericality: { greater_than: 0, less_than: 150 }, allow_nil: true
endZodRails generates:
import { z } from "zod";
export const UserSchema = z.object({
id: z.int(),
email: z.string().min(1).regex(/^[^@\s]+@[^@\s]+$/),
name: z.string().min(2).max(100),
age: z.int().gt(0).lt(150).nullable(),
role: z.enum(["member", "admin", "moderator"]),
created_at: z.iso.datetime(),
updated_at: z.iso.datetime()
});
export type User = z.infer<typeof UserSchema>;
export const UserInputSchema = z.object({
email: z.string().min(1).regex(/^[^@\s]+@[^@\s]+$/),
name: z.string().min(2).max(100),
age: z.int().gt(0).lt(150).nullish(),
role: z.enum(["member", "admin", "moderator"]).optional()
});
export type UserInput = z.infer<typeof UserInputSchema>;ZodRails generates two schema variants:
Schema (e.g., UserSchema)
- Represents data as returned from your API
- Includes all columns (
id, timestamps, etc.) - Uses
.nullable()for nullable columns
InputSchema (e.g., UserInputSchema)
- Represents data for form submission
- Excludes configured columns (defaults:
id,created_at,updated_at) - Uses
.optional()for columns with database defaults - Uses
.nullish()for nullable columns (accepts bothnullandundefined)
ZodRails pairs well with form libraries that support Zod:
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { UserInputSchema, type UserInput } from "./schemas/user";
function UserForm() {
const { register, handleSubmit, formState: { errors } } = useForm<UserInput>({
resolver: zodResolver(UserInputSchema)
});
const onSubmit = (data: UserInput) => {
// data is fully typed and validated
};
return <form onSubmit={handleSubmit(onSubmit)}>...</form>;
}import { UserSchema, type User } from "./schemas/user";
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return UserSchema.parse(data); // Throws if invalid
}Add schema generation to your build process to catch type mismatches early:
# .github/workflows/ci.yml
- name: Generate Zod schemas
run: bin/rails zod_rails:generate
- name: Check for uncommitted schema changes
run: git diff --exit-code app/javascript/schemas/Re-run the generator after any model changes:
bin/rails zod_rails:generateEnsure validations are defined on the model class, not in concerns that might not be loaded. Conditional validations (:if, :unless) are detected and excluded by default.
For custom types not in the mapping table, ZodRails falls back to z.unknown(). Open an issue if you need support for additional types.
- Bump the version in
lib/zod_rails/version.rb - Commit:
git commit -am 'Bump version to x.y.z' - Tag:
git tag v<x.y.z> - Push with tags:
git push origin trunk --tags
The v* tag push triggers the release workflow, which runs CI and publishes to RubyGems via Trusted Publishing.
After checking out the repo:
bundle install
bundle exec rspec- Fork it
- Create your feature branch (
git checkout -b feature/my-feature) - Commit your changes (
git commit -am 'Add my feature') - Push to the branch (
git push origin feature/my-feature) - Create a Pull Request
MIT License. See LICENSE for details.