intertwingly

It’s just data

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.


Railcar is open source. Ruby2JS is its sibling project.