End-to-end type system for Rails
Define types once in Ruby DSL. Generate TypeScript interfaces and Zod schemas for your frontend.
Documentation · Quick Start · GitHub
Overview
Zodra is a full API framework for Rails that bridges the gap between backend and frontend type safety. You define your types, contracts, and API structure in a Ruby DSL — Zodra handles validation, serialization, routing, and code generation.
One definition, both sides:
Ruby DSL → TypeScript interfaces + Zod schemas
→ Params validation + Response serialization
→ Resource routing + Controller helpers
Features
- Type DSL — objects, enums, unions with full attribute support (optional, nullable, defaults, constraints)
-
Type Composition — derive types with
from:,pick:,omit:,partial:(like TypeScript utility types) - Contracts — define params and responses per action, decoupled from routing
-
Resource Routing —
Zodra.apimaps contracts to RESTful routes with nested resources and custom actions - Params Validation — strict by default, coerces and validates incoming params against contract schemas
-
Response Serialization — consistent
{ data: ... }/{ data: [...], meta: ... }envelope -
TypeScript Export — generates
.tsinterfaces from your type definitions -
Zod Export — generates
.tsschemas with constraints (z.string().min(1),z.number().int()) -
Controller Mixin —
include Zodra::Controllerforzodra_params,zodra_respond, error handling -
OpenAPI 3.1 — auto-generated specs from your contracts with
rake zodra:openapi -
Swagger UI — built-in interactive API docs at
/docswithmount Zodra::Swagger - Rails Native — Railtie, rake tasks, works with Zeitwerk and standard Rails conventions
Quick Start
Installation
Add to your Gemfile:
gem "zodra"1. Define a Type
# app/types/product.rb
Zodra.type :product do
uuid :id
string :name
string :sku
decimal :price
integer :stock
boolean :published
end2. Define a Contract
# app/contracts/products.rb
Zodra.contract :products do
action :index do
response :product, collection: true
end
action :show do
params do
uuid :id
end
response :product
end
action :create do
params do
string :name, min: 1
string :sku, min: 1
decimal :price, min: 0
integer :stock, min: 0
boolean :published, default: false
end
response :product
end
action :update do
params do
uuid :id
string? :name, min: 1
string? :sku, min: 1
decimal? :price, min: 0
integer? :stock, min: 0
boolean? :published
end
response :product
end
action :destroy do
params do
uuid :id
end
end
end3. Define the API
# config/apis/v1.rb
Zodra.api "/api/v1" do
resources :products
end4. Set Up Routes
# config/routes.rb
Rails.application.routes.draw do
mount Zodra::Swagger => '/docs' # Swagger UI at /docs
zodra_routes
end5. Write the Controller
# app/controllers/api/v1/products_controller.rb
module Api
module V1
class ProductsController < ApplicationController
include Zodra::Controller
zodra_contract :products
def index
products = Product.all
zodra_respond_collection(products)
end
def show
product = Product.find(zodra_params[:id])
zodra_respond(product)
end
def create
product = Product.create!(zodra_params)
zodra_respond(product, status: :created)
end
def update
product = Product.find(zodra_params[:id])
product.update!(zodra_params.except(:id))
zodra_respond(product)
end
def destroy
product = Product.find(zodra_params[:id])
product.destroy!
head :no_content
end
end
end
end6. Export to TypeScript + Zod
bin/rails zodra:export
# Generated app/javascript/types/schemas.ts
# Generated app/javascript/types/types.tsGenerated TypeScript:
export interface Product {
id: string;
name: string;
sku: string;
price: number;
stock: number;
published: boolean;
}Generated Zod:
import { z } from 'zod';
export const ProductSchema = z.object({
id: z.uuid(),
name: z.string(),
sku: z.string(),
price: z.number(),
stock: z.number().int(),
published: z.boolean(),
});Type DSL
Primitive Types
| DSL | TypeScript | Zod |
|---|---|---|
string |
string |
z.string() |
integer |
number |
z.number().int() |
decimal |
number |
z.number() |
boolean |
boolean |
z.boolean() |
uuid |
string |
z.uuid() |
date |
string |
z.iso.date() |
datetime |
string |
z.iso.datetime() |
Modifiers
string? :nickname # optional
string :name, nullable: true # nullable
string :role, default: "user"
string :name, min: 1, max: 100Enums
Zodra.enum :status, values: %w[draft published archived]Unions
Zodra.union :payment, discriminator: :type do
variant :card do
string :last_four
end
variant :bank_transfer do
string :iban
end
endReferences and Arrays
Zodra.type :order do
uuid :id
reference :customer # references Zodra.type :customer
array :items, of: :line_item # array of Zodra.type :line_item
endType Composition
Derive new types from existing ones — like TypeScript's Pick, Omit, and Partial:
Zodra.type :create_product_params, from: :product, omit: [:id]
Zodra.type :update_product_params, from: :product, omit: [:id], partial: true
Zodra.type :product_summary, from: :product, pick: [:id, :name, :price]Attribute Blocks
Blocks replace the default value extraction in ResponseSerializer. Use them when
the attribute value comes from a different source than the object's method:
Zodra.type :clock_in_day do
integer :id
string :date do |day|
UiDate.format(day.date)
end
boolean :can_edit do |day, context|
context[:ability].can?(:edit, day)
end
reference :staff_member, to: :staff do |day|
StaffPresenter.new(day.staff_member)
end
endPass context through ResponseSerializer.call(object, definition, context: { ability: current_ability }).
Configuration
# config/initializers/zodra.rb
Zodra.configure do |config|
config.output_path = "app/javascript/types" # where to write generated files
config.key_format = :camel # :camel, :pascal, or :keep
config.zod_import = "zod" # import path for Zod
config.strict_params = true # reject unknown params
config.strict_serialization = true # raise on missing required attributes
# OpenAPI / Swagger UI
config.openapi_title = "My API"
config.openapi_version = "1.0.0"
config.openapi_description = "API documentation"
endRake Tasks
bin/rails zodra:export # generate both TypeScript + Zod
bin/rails zodra:export:typescript # generate only TypeScript interfaces
bin/rails zodra:export:zod # generate only Zod schemas
bin/rails zodra:openapi # generate OpenAPI 3.1 JSON specsProject Structure
zodra/
├── gem/ # Ruby gem (zodra)
│ ├── lib/zodra/ # Core library
│ └── spec/ # RSpec tests
├── js/ # JavaScript packages (pnpm workspace)
│ └── packages/
│ ├── client/ # @zodra/client
│ └── vscode/ # VS Code extension
├── website/ # Next.js documentation (zodra.dev)
├── example/ # Full Rails API app (smoke test)
└── .github/workflows/ # CI (Ruby 3.2/3.3/3.4 + Node 22)
Development
Requirements
- Ruby >= 3.2
- Node.js >= 22
- pnpm >= 10
Ruby (gem)
cd gem
bundle install
bundle exec rspec # run tests
bundle exec rubocop # lintJavaScript (packages)
cd js
pnpm install
pnpm test # run tests
pnpm typecheck # type checkExample App
cd example
bundle install
bin/rails db:create db:migrate db:seed
bin/rails server
# CRUD: curl http://localhost:3000/api/v1/products