Crystal on Rails
Compiling arbitrary Ruby is a problem that's been attempted many times and never solved. The language is too dynamic — method_missing, eval, define_method, open classes. Any expression can change the meaning of any other expression at runtime. Static analysis can't keep up.
Generating type signatures for arbitrary Ruby has the same problem. Sorbet and RBS can annotate what you tell them to annotate, but inferring types from source alone? The dynamism defeats you. A method might return a String or a User or nil depending on runtime state that no static tool can see.
You'd think that a full Rails application would be harder — more code, more patterns, more moving parts. The opposite turns out to be true.
Why Rails Is Easier
Rails is convention over configuration. That phrase usually describes the developer experience, but it also describes the analysis experience. When you see has_many :comments, dependent: :destroy, you know:
- There's a
commentsmethod that returns a collection - The collection contains
Commentobjects - The foreign key is
article_id - Destroying the parent destroys the children
You don't have to trace through metaprogramming to figure this out. The convention is the specification. The same applies to validates, belongs_to, before_action, resources, form_with, render, redirect_to — each is a declarative statement with known semantics and known types.
A Rails application isn't arbitrary Ruby. It's a structured DSL where the patterns are finite, documented, and consistent across every Rails app ever written. That's a tractable problem.
Why Crystal
Crystal shares roughly 90% of Ruby's syntax — classes, methods, blocks, strings, symbols, hashes, arrays, inheritance, modules. The overlap isn't accidental; Crystal was designed to feel like Ruby. And the overlap is exactly where Rails lives. A model like this is valid in both languages:
class Article < ApplicationRecord
has_many :comments, dependent: :destroy
validates :title, presence: true
validates :body, presence: true, length: { minimum: 10 }
end
What Crystal adds is a real type system. Not bolted on — built in. The compiler does global type inference: you write Ruby-like code, and it knows the type of every expression at every point. No separate type files, no annotations in most cases. The type errors that Ruby surfaces at runtime, Crystal catches at compile time.
The runtime that Railcar generates is fully typed. Relation(T) is a generic — Article.where(title: "foo") returns Relation(Article), and the compiler knows that .to_a returns Array(Article), .first returns Article?, .count returns Int64. CollectionProxy(T) works the same way — article.comments returns CollectionProxy(Comment), and every method on it carries the type through. The column macro generates typed getters and setters. Validations, associations, query chaining — all with compile-time type checking, all with the same API Rails developers already know.
In Rails, these return types are invisible — you rely on documentation and runtime behavior. Here, the compiler enforces them.
For Rails developers considering Crystal, a rewrite is required. Railcar lowers that barrier. Same patterns, same conventions, incremental migration. Your Rails app, with types.
Three Things
Railcar exploits this in three ways:
Transpiler. Given a Rails app directory, Railcar parses the source with Prism, transforms the AST through a chain of composable filters, and generates a Crystal application that compiles and runs. Models, controllers, views, routes, tests — the full stack.
Framework. The generated application runs on a Rails-compatible Crystal runtime — an ActiveRecord-like ORM with macros, query chaining, validations, and associations; Hotwire support with Turbo Streams and Action Cable. Not a wrapper around Rails, but a native Crystal implementation of the patterns Rails developers already know.
RBS generator. The same metadata extraction that powers the transpiler — reading migrations for column types, models for associations, routes for path helpers — can emit RBS type signatures for the original Rails app. This is a prototype today, but the path forward is clear: the metadata pipeline handles cross-file Rails semantics (the schema defines column types; has_many :comments determines what article.comments returns), while Crystal's type checker handles within-file precision (nil safety, method resolution, control flow narrowing). They're complementary — combining both should produce comprehensive type coverage that neither achieves alone.
These mix and match. Both .rb and .cr input files are supported in the same project, so you can start with a Rails app, generate the Crystal version, then gradually rewrite individual files in Crystal. The pipeline handles both seamlessly.
The Pipeline
Every file — model, controller, or template — flows through the same pipeline:
Source file (.rb or .cr)
|
v
SourceParser
|
.rb files: Prism FFI -> Crystal AST
.cr files: Crystal parser -> Crystal AST
|
v
Filter chain (composable transformations)
|
v
Crystal source output
Crystal AST is the canonical intermediate representation. All filters operate on it regardless of whether the source was Ruby or Crystal. The filters are where Rails knowledge lives — 18 small transformers, each handling one pattern:
| Filter | What it does |
|---|---|
InstanceVarToLocal |
@article -> article |
StrongParams |
article_params -> extract_model_params(params, "article") |
RedirectToResponse |
redirect_to @article -> status 302 + Location header |
RenderToECR |
render :new -> ECR.embed(...) |
ModelBoilerplate |
Wraps in model("table") {}, adds columns and validations |
LinkToPathHelper |
link_to(@article) -> link_to(article_path(article)) |
Each filter is a Crystal::Transformer subclass, typically under 100 lines. Adding support for a new Rails pattern means writing a new filter and inserting it into the chain.
The Honest Assessment
The architecture is solid. The implementation is held together with bailing wire and chewing gum.
The database is hard-coded to SQLite. The layout is a single inline HTML template. The port is always 3000. Flash messages are a process-global hash. The form builder is embedded in the ERB converter instead of being a proper filter. These shortcuts are in place not because they're hard to fix, but because they're known to be solvable — each has a clear path to a proper implementation.
Despite all of that, the proof of concept produces real results. A Rails blog with articles, comments, nested resources, validations, Turbo Streams, and 243 passing tests compiles to Crystal and runs.
Relationship to Ruby2JS
Railcar is a sibling project to Ruby2JS. They share the same Prism parser, the same inflector, and the same philosophy: parse source, transform an AST through composable filters, serialize the result. Where Ruby2JS targets JavaScript and seven deployment platforms, Railcar targets Crystal — a language with Ruby's syntax and a real type system.
The two projects go in opposite directions. Ruby2JS takes Ruby source and produces JavaScript. Railcar takes Ruby source and produces Crystal. But the metadata extraction — reading migrations, models, routes, controllers — is the same Rails-convention-aware pipeline. Conventions learned in one transfer to the other.
Try It
Prerequisites: Crystal (>= 1.10.0), Ruby with the prism gem, SQLite3 headers. For styled output, install tailwindcss or gem install tailwindcss-rails.
git clone https://github.com/rubys/railcar
cd railcar
make
shards install
make test # 243 specs
To convert a Rails app:
npx github:ruby2js/juntos --demo blog
./build/railcar blog crystal-blog
cd crystal-blog
shards install
crystal build src/app.cr -o blog
./blog
Source code. Architecture guide. MIT licensed.