Elixir on Rails
A few hours ago, Railcar could transpile a Rails app to Crystal, Python, and TypeScript. Now it can also transpile to Elixir.
This is the fourth target, and the first functional language. The same demo blog — articles, comments, nested resources, validations, Turbo Streams — generates a working Elixir web application. 21 tests pass. The app serves styled pages with Tailwind CSS, handles real-time updates via ActionCable WebSockets, and runs on Plug + Bandit.
Why Elixir Matters
Rails is to web applications what SQL is to data — a declarative specification of what you want, separating intent from execution. has_many :comments, dependent: :destroy declares a relationship; it says nothing about how to query it, how to cascade deletes, or whether the runtime is object-oriented or functional. validates :title, presence: true declares a constraint, not an implementation. Routes, controllers, even the if @article.save / else pattern in a scaffold — these are declarations of intent, not instructions for a specific runtime.
Juntos and Railcar are the query planners. Juntos generates the Rails app from a higher-level spec. Railcar reads that app and produces an execution plan for a different runtime — choosing the right idioms, patterns, and abstractions for each target language.
Crystal, Python, and TypeScript are all imperative, object-oriented languages. Translating between them is primarily a syntax exercise — the execution plan looks a lot like the original. Elixir is the first target where the execution plan looks fundamentally different:
- No classes. Models are modules with structs.
class Article < ApplicationRecordbecomesdefmodule Blog.Article do use Railcar.Record. - No mutable state.
article.savedoesn't mutate the article — it returns{:ok, saved_article}or{:error, article_with_errors}. - No method calls on objects.
article.commentsbecomesBlog.Article.comments(article)— a module function that takes the record as its first argument. - Pattern matching replaces conditionals.
if article.savebecomescase Blog.Article.create(params) do {:ok, article} -> ... {:error, _} -> ... end. - Pipe operator replaces sequential statements.
redirect_to @articlebecomesconn |> put_resp_header("location", path) |> send_resp(302, ""). - Processes replace shared state. The database connection uses
:persistent_terminstead of a global variable, and the WebSocket server is aGenServer.
The same declarations, a completely different execution plan. If that works, the spec really is declarative.
What Changed
The pipeline now has four output paths:
Rails source
|
v
Prism parser -> Crystal AST
|
v
Shared filters (9 transformers)
|
+--> Crystal filters --> .cr/.ecr output
|
+--> Python filters --> Cr2Py --> .py output
|
+--> TypeScript filters --> Cr2Ts --> .ts/.ejs output
|
+--> Elixir filters --> Cr2Ex --> .ex/.eex output
Elixir follows the same parse → filter → emit architecture as the other targets. Models and controllers are parsed into Crystal AST, transformed through a filter chain, then emitted as Elixir by Cr2Ex. Views go through the EexConverter which follows the same pattern as ERBConverter (Crystal) and EjsConverter (TypeScript) — walking the filtered AST and emitting template tags.
The key difference is what the filters do. For imperative targets, filters mostly rearrange syntax. For Elixir, they perform semantic transformations — converting if article.save into case Model.create(params) do {:ok, article} -> ..., threading record as the first parameter through module functions, and resolving association chains into qualified module calls. The emitter then renders these transformed AST nodes as idiomatic Elixir.
Idiomatic Elixir
The generated code uses Elixir idioms, not transliterated Ruby:
Pattern matching on save results:
def create(conn) do
params = extract_model_params(conn.body_params, "article")
case Blog.Article.create(params) do
{:ok, article} ->
conn |> put_resp_header("location", Helpers.article_path(article)) |> send_resp(302, "")
{:error, article} ->
Helpers.render_view(conn, "articles/new", [{:article, article}], 422)
end
end
Module functions instead of methods:
defmodule Blog.Article do
use Railcar.Record, table: "articles", columns: [:title, :body, :created_at, :updated_at]
def comments(record) do
Blog.Comment.where(%{article_id: record.id})
end
def run_validations(record) do
errors = []
errors = errors ++ Railcar.Validation.validate_presence(record, :title)
errors = errors ++ Railcar.Validation.validate_presence(record, :body)
errors = errors ++ Railcar.Validation.validate_length(record, :body, minimum: 10)
errors
end
end
EEx templates with keyword args:
<%= Blog.Helpers.link_to(article.title, Blog.Helpers.article_path(article),
class: "text-blue-600 hover:underline") %>
GenServer for WebSocket state:
defmodule Railcar.CableServer do
use GenServer
def broadcast(channel, html) do
GenServer.cast(__MODULE__, {:broadcast, channel, html})
end
end
Design Decisions
Plug + Bandit, not Phoenix. Phoenix is built on Plug. Every Phoenix controller is a Plug, the router is a Plug. Starting with Plug means the generated code can be dropped into a Phoenix project later — or used standalone. Same reasoning as Express (not NestJS) and aiohttp (not Django).
Exqlite, not Ecto. The hand-written Railcar.Record macro provides an ActiveRecord-like API backed by direct SQLite access. It's simpler than Ecto and maps more directly to the Rails patterns. An Ecto adapter could be added later.
:persistent_term for cross-process state. Elixir runs each request in a separate process. The database connection and broadcast partial functions are stored in :persistent_term — a read-optimized global store designed for values that rarely change.
Overridable functions for callbacks. Models override after_save/1 and after_delete/1 for broadcast callbacks, and run_validations/1 for validations. This follows the defoverridable pattern — idiomatic Elixir for extensible behaviour.
The Numbers
| Crystal | Python | TypeScript | Elixir | |
|---|---|---|---|---|
| Specs/tests | 313 | 21 | 21 | 21 |
| Model tests | yes | 9 passing | 9 passing | 9 passing |
| Controller tests | yes | 12 passing | 12 passing | 12 passing |
| HTTP server | Crystal HTTP | aiohttp | Express | Plug + Bandit |
| ORM | Macro-based | COLUMNS + setattr | COLUMNS + index sig | Railcar.Record macro |
| Views | ECR templates | String functions | EJS templates | EEx templates |
| Turbo Streams | ActionCable | ActionCable (WS) | ActionCable (ws) | ActionCable (WebSock) |
| CSS | Tailwind CLI | Tailwind CLI | Tailwind CLI | Tailwind CLI |
| Test framework | Crystal Spec | pytest | node:test | ExUnit |
| Paradigm | OOP | OOP | OOP | Functional |
The Honest Assessment
All 21 tests pass. The app compiles without warnings. Turbo Streams broadcasting works. Here's what that doesn't tell you.
One app, one paradigm test. The blog demonstrates that Rails CRUD maps to Elixir's functional patterns. The mapping is derived from AST transformation — the same parse → filter → emit pipeline as the imperative targets — but the filters currently handle standard CRUD patterns. More complex Rails patterns (concerns, STI, polymorphic associations, custom controller logic) would need new filters.
Imperative-to-functional transforms are narrow. The Elixir filters know how to convert if article.save into a case expression, thread record through module functions, and resolve association chains. But they don't yet perform general-purpose transformations like SSA renaming (for arbitrary mutable state) or full OOP-to-module dispatch (for custom methods calling other methods on model instances). Those techniques exist — Appel showed that SSA is equivalent to functional programming — but they aren't needed for the blog demo and can be added incrementally as more complex apps expose the need.
Process model assumptions. The Railcar.Record macro uses :persistent_term for the database connection, which means all requests share one SQLite connection. This works for a demo but not for production concurrent access. A real Elixir app would use a connection pool (like db_connection).
Try It
Browse the generated code — compare the original Ruby source side-by-side with Crystal, Python, TypeScript, and Elixir output.
To generate and run the Elixir blog locally:
git clone https://github.com/rubys/railcar
cd railcar
make
npx github:ruby2js/juntos --demo blog
./build/railcar --elixir blog ex-blog
cd ex-blog
mix deps.get
mix test --no-start
mix run --no-halt
Open multiple browser tabs at http://localhost:3000 to see real-time Turbo Streams updates.
Crystal, Python, and TypeScript targets are also available. See the README for details.
Source code. Architecture guide. MIT licensed.