TypeScript on Rails
Yesterday, Railcar could transpile a Rails app to Crystal and Python. Now it can also transpile to TypeScript.
The same demo blog — articles, comments, nested resources, validations, Turbo Streams — generates a working TypeScript web application. 21 tests pass. The app serves styled pages with Tailwind CSS, handles real-time updates via ActionCable WebSockets, and runs on Express.
Five Hours
Crystal took about 20 hours — it was the first target, and it meant inventing the entire pipeline. Python took two and a half days, building a more sophisticated emitter with an intermediate Python AST and async/await handling. TypeScript took five hours.
The speed came from two things. First, the shared infrastructure was already in place: 9 shared filters, AppModel extraction, RouteHelper data model, semantic analysis stubs, ErbCompiler. About 60% of the work was done before writing a single TypeScript-specific line. Second, TypeScript's syntax is close enough to Crystal that a direct AST-to-source emitter works — no intermediate AST needed.
What Changed
The pipeline now has three output paths:
Rails source
|
v
Prism parser -> Crystal AST
|
v
Shared filters (9 transformers)
|
+--> Crystal filters --> Crystal.format --> .cr/.ecr output
|
+--> Python filters --> Cr2Py --> PyAST --> .py output
|
+--> TypeScript filters --> Cr2Ts --> .ts/.ejs output
TypeScript reuses semantic analysis from the Python pipeline — Crystal's program.semantic() infers types on model ASTs, providing typed declarations in the output. But it skips the intermediate AST (PyAST) that Python needs. The Crystal-to-TypeScript mapping is direct enough that the emitter walks Crystal AST nodes and writes TypeScript strings.
Views take the Crystal approach, not the Python approach. Python generates template literal functions — views are .py files with _buf += string building. TypeScript generates EJS template files — the same pattern as Crystal's ERB-to-ECR conversion. The EjsConverter walks the filtered AST and emits <%= %> and <% %> tags with JavaScript expressions:
<h1><%= article.title %></h1>
<%= helpers.linkTo("Edit", helpers.editArticlePath(article), { class: "btn" }) %>
<% for (const comment of article.comments()) { %>
<%- include("../comments/_comment", { comment, helpers }) %>
<% } %>
Controllers are Express route handlers, generated through the same AST filter pipeline as Crystal and Python:
export function create(req: Request, res: Response, data?: Record<string, unknown>): void {
if (data == null) { data = req.body; }
const article = new Article(helpers.extractModelParams(data, "article"));
if (article.save()) {
res.redirect(helpers.articlePath(article));
} else {
helpers.renderView(res, "articles/new", article, 422);
}
}
Design Decisions
EJS templates, not code generation. The Python target generates views as Python functions that build strings. This works but produces code that no human would write. The TypeScript target generates .ejs template files — standard EJS that looks like what a developer would create by hand. The EjsConverter mirrors the Crystal target's ERBConverter: both walk the filtered AST and re-emit template syntax, preserving the HTML structure.
Express, not a framework. Express is the most widely used Node.js web framework, with well-typed TypeScript definitions. Controllers are plain functions, routing is straightforward, and the urlencoded middleware handles form parsing. No custom framework abstractions.
better-sqlite3. Synchronous SQLite, matching Crystal's approach. No async/await complexity in the ORM layer. The ApplicationRecord base class provides the same API across all three targets: find, all, where, create, save, update, destroy, validations, associations, and broadcast callbacks.
Node's built-in test runner. Tests use node:test and node:assert — zero test framework dependencies. Integration tests use supertest for HTTP assertions against the Express app.
Typed model methods. Each generated model class overrides the base find, all, create, where, and last methods with return types specific to that model. Article.find(1) returns Article, not ApplicationRecord. Association methods like article.comments() work without casting. The generated code has zero as any casts — every property access and method call is type-checked under tsc --strict.
Shared model filter. The ModelBoilerplatePython filter (originally written for Python) produces macro-free Crystal AST that works for TypeScript too — TABLE, COLUMNS, property declarations, association methods, runValidations, dependent destroy. The filter is target-neutral despite its name.
The Numbers
| Crystal | Python | TypeScript | |
|---|---|---|---|
| Specs/tests | 313 | 21 | 21 |
| Model tests | yes | 9 passing | 9 passing |
| Controller tests | yes | 12 passing | 12 passing |
| HTTP server | Crystal HTTP | aiohttp async | Express |
| ORM | Macro-based, typed | COLUMNS + setattr | COLUMNS + index signature |
| Views | ECR templates | Python string functions | EJS templates |
| Turbo Streams | ActionCable | ActionCable (WebSocket) | ActionCable (ws) |
| CSS | Tailwind CLI | Tailwind CLI | Tailwind CLI |
| Test framework | Crystal Spec | pytest + pytest-asyncio | node:test + supertest |
| Type system | Built-in | Type hints from inference | declare + strict mode |
The Honest Assessment
All 21 tests pass. The generated TypeScript compiles clean under tsc --strict. Here's what that doesn't tell you.
One app. Same caveat as Python. The blog is a good exercise of common patterns, but it's one app.
Type safety has limits. The generated models are fully typed — Article.find() returns Article, property access is checked, associations resolve to the right type. But EJS templates are still dynamically scoped — a typo in <%= article.titl %> is a runtime error, not a type error. The same is true of Crystal's ECR templates.
Model lifecycle rendering. The broadcast partial rendering uses ejs.render() at runtime, reading template files from disk on each broadcast. In production this would want caching. The Python target has the same pattern with its string-building partials.
Try It
Browse the generated code — compare the original Ruby source side-by-side with Crystal, Python, and TypeScript output. Or run the blog in your browser via Ruby2JS.
To generate and run the TypeScript blog locally:
git clone https://github.com/rubys/railcar
cd railcar
make
npx github:ruby2js/juntos --demo blog
./build/railcar --typescript blog ts-blog
cd ts-blog
npm install
npx tsx --test tests/*.test.ts
npx tsx app.ts
Open multiple browser tabs at http://localhost:3000 to see real-time Turbo Streams updates. Set LOG_LEVEL=debug for Rails-style request logging.
Crystal and Python targets are also available (--crystal, --python). See the README for details.
Source code. Architecture guide. MIT licensed.