Go on Rails
Earlier today, Railcar could transpile a Rails app to Crystal, Python, TypeScript, and Elixir. Now it can also transpile to Go.
This is the fifth target. The same demo blog — articles, comments, nested resources, validations, Turbo Streams — generates a working Go web application. 21 tests pass. The app serves styled pages with Tailwind CSS, handles real-time updates via ActionCable WebSockets, and runs on Go's standard net/http.
The Template Problem
Every other target has a template engine. Crystal has ECR. TypeScript has EJS. Elixir has EEx. Python builds strings with f-strings. For Go, the obvious choice was html/template.
It didn't work.
Go's html/template is deliberately limited. It can't call methods that return (value, error) — which is how Go idiomatically handles fallible operations. article.Comments() returns ([]*Comment, error). You can't range over that. You can't pass it to len. Every association call, every model constructor, every operation that might fail is unusable directly in a template.
We tried workarounds: a safeCall function to unwrap the error, a dict function to construct data maps for partials, pre-computing all associations in controllers and passing them as template data. Each fix exposed the next edge case. Partials needed parent context passed through dict. Nested function calls needed careful parenthesization. The os.Chdir hack in tests was the final straw.
The fix was to stop fighting the template engine and ask: what does Python do?
Views as Functions
Python doesn't use a template engine. Views are functions that return strings:
def render_show(article, notice=None):
_buf = ""
_buf += f"<h1>{article.title}</h1>"
for comment in article.comments():
_buf += render_comment_partial(article, comment)
return _buf
Go can do the same thing:
func RenderShow(article *models.Article) string {
var buf strings.Builder
buf.WriteString("<h1>")
buf.WriteString(article.Title)
buf.WriteString("</h1>")
comments, _ := article.Comments()
for _, comment := range comments {
buf.WriteString(RenderCommentPartial(article, comment))
}
return buf.String()
}
No template engine. Method calls just work. (value, error) returns are handled with normal Go error handling. Partials are function calls with explicit, typed parameters. The compiler catches typos. And strings.Builder is more efficient than fmt.Sprintf with format strings.
The switch deleted 512 lines of GoTemplateConverter, removed all the safeCall/dict/precomputed-deps machinery, and immediately got all 21 tests passing.
What Changed
The pipeline now has five output paths:
Rails source
|
v
Prism parser -> Crystal AST
|
v
Shared filters (9 transformers)
|
+--> Crystal filters --> .cr/.ecr output
|
+--> Elixir filters --> Cr2Ex --> .ex/.eex output
|
+--> Go filters --> Cr2Go / GoViewEmitter --> .go output
|
+--> Python filters --> Cr2Py --> .py output
|
+--> TypeScript filters --> Cr2Ts --> .ts/.ejs output
Go views use the same filter chain as Python (shared view filters + ViewCleanup) but skip BufToInterpolation — Go emits individual buf.WriteString() calls instead of merging into a single fmt.Sprintf, which is both more readable and more efficient.
Models use Cr2Go which emits Go structs implementing a Model interface. Controllers are generated structurally from AppModel metadata. The hand-written runtime (railcar.go) provides generic CRUD with Go generics, a Broadcaster interface for after-save/after-delete callbacks, and a full Action Cable WebSocket server.
Idiomatic Go
The generated code uses Go idioms, not transliterated Ruby:
Structs implementing interfaces:
type Article struct {
Id int64
Title string
Body string
CreatedAt string
UpdatedAt string
persisted bool
errors []railcar.ValidationError
}
func (m *Article) RunValidations() []railcar.ValidationError {
var errors []railcar.ValidationError
if m.Title == "" {
errors = append(errors, railcar.ValidationError{Field: "title", Message: "can't be blank"})
}
return errors
}
Generic CRUD in the runtime:
func All[T Model](factory func() T, tableName string, orderBy string) ([]T, error) {
rows, err := DB.Query("SELECT * FROM " + tableName + " ORDER BY " + orderBy)
// ...
}
View functions with typed parameters:
func RenderCommentPartial(article *models.Article, comment *models.Comment) string {
var buf strings.Builder
buf.WriteString("<div id=\"")
buf.WriteString(helpers.DomID(comment))
buf.WriteString("\" class=\"p-4 bg-gray-50 rounded\">")
// ...
return buf.String()
}
Broadcaster interface for model callbacks:
func (m *Comment) AfterSave() {
railcar.BroadcastReplaceTo(m, fmt.Sprintf("article_%d_comments", m.ArticleId), "comments")
if article, err := m.Article(); err == nil {
railcar.BroadcastReplaceTo(article, "articles", "")
}
}
Design Decisions
net/http, not a framework. Go 1.22's enhanced ServeMux handles method-based routing (GET /articles/{id}) natively. No need for Gin, Chi, or Echo. The generated code has zero framework dependencies beyond the standard library.
modernc.org/sqlite, not CGO. A pure-Go SQLite implementation. No C compiler needed, cross-compilation works, and it's the standard choice for Go apps that want SQLite without CGO.
nhooyr.io/websocket for Action Cable. Supports subprotocol negotiation (actioncable-v1-json), which Action Cable requires. The Turbo client-side JavaScript will close the connection without it.
View functions, not templates. As described above. This also means views are compiled, type-checked, and produce no runtime template parsing overhead. One tradeoff: html/template auto-escapes user content; view functions don't. The emitter distinguishes safe HTML (helper output like LinkTo, ButtonTo) from user data (model fields like Title, Body) and wraps user data in html.EscapeString().
strings.Builder, not fmt.Sprintf. Each HTML fragment is a separate buf.WriteString() call. No format string parsing, no interface{} boxing, and the output is readable — you can see where each helper call contributes to the HTML.
The Numbers
| Crystal | Elixir | Go | Python | TypeScript | |
|---|---|---|---|---|---|
| Tests | 313 | 21 | 21 | 21 | 21 |
| Model tests | yes | 9 passing | 9 passing | 9 passing | 9 passing |
| Controller tests | yes | 12 passing | 12 passing | 12 passing | 12 passing |
| HTTP server | Crystal HTTP | Plug + Bandit | net/http | aiohttp | Express |
| ORM | Macro-based | Railcar.Record | Model interface + generics | COLUMNS + setattr | COLUMNS + index sig |
| Views | ECR templates | EEx templates | Go functions | String functions | EJS templates |
| Turbo Streams | ActionCable | ActionCable (WebSock) | ActionCable (nhooyr) | ActionCable (WS) | ActionCable (ws) |
| CSS | Tailwind CLI | Tailwind CLI | Tailwind CLI | Tailwind CLI | Tailwind CLI |
| Test framework | Crystal Spec | ExUnit | go test | pytest | node:test |
The Honest Assessment
All 21 tests pass. The app compiles clean. Broadcasting works. Here's what that doesn't tell you.
The template detour cost time. We spent significant effort on html/template before recognizing it was the wrong approach. The view-functions approach was implemented in a fraction of the time and immediately worked. The lesson: when fighting a library, question the library choice.
Go's error handling is verbose but explicit. Every (value, error) return is handled. Controllers return 404 for invalid or missing IDs, 500 for failed queries, and 422 for validation failures. No errors are silently discarded.
One app. Same caveat as every other target. The blog exercises standard CRUD, validations, nested resources, and Turbo Streams. More complex patterns would need new filters.
Try It
Browse the generated code — compare the original Ruby source side-by-side with Crystal, Elixir, Go, Python, and TypeScript output.
To generate and run the Go blog locally:
git clone https://github.com/rubys/railcar
cd railcar
make
npx github:ruby2js/juntos --demo blog
./build/railcar --go blog go-blog
cd go-blog
go mod tidy
go test ./...
go run .
Open multiple browser tabs at http://localhost:3000 to see real-time Turbo Streams updates. Set LOG_LEVEL=debug for verbose request and WebSocket logging.
All five targets are available. See the README for details.
Source code. Architecture guide. MIT licensed.