Agent Skills: Elm ↔ Roc Conversion

Bidirectional conversion between Elm and Roc. Use when migrating projects between these languages in either direction. Extends meta-convert-dev with Elm↔Roc specific patterns. Use when migrating Elm frontend code to Roc applications, translating browser-based Elm to platform-agnostic Roc, or refactoring Elm web applications to Roc CLI/native tools. Extends meta-convert-dev with Elm-to-Roc specific patterns.

UncategorizedID: arustydev/ai/convert-elm-roc

Repository

aRustyDevLicense: AGPL-3.0
72

Install this agent skill to your local

pnpm dlx add-skill https://github.com/aRustyDev/agents/tree/HEAD/content/skills/convert-elm-roc

Skill Files

Browse the full folder contents for convert-elm-roc.

Download Skill

Loading file tree…

content/skills/convert-elm-roc/SKILL.md

Skill Metadata

Name
convert-elm-roc
Description
Bidirectional conversion between Elm and Roc. Use when migrating projects between these languages in either direction. Extends meta-convert-dev with Elm↔Roc specific patterns. Use when migrating Elm frontend code to Roc applications, translating browser-based Elm to platform-agnostic Roc, or refactoring Elm web applications to Roc CLI/native tools. Extends meta-convert-dev with Elm-to-Roc specific patterns.

Elm ↔ Roc Conversion

Bidirectional conversion between Elm and Roc. This skill extends meta-convert-dev with Elm↔Roc specific type mappings, idiom translations, and architectural patterns for moving from browser-based Elm applications to platform-agnostic Roc code.

This Skill Extends

  • meta-convert-dev - Foundational conversion patterns (APTV workflow, testing strategies)

For general concepts like the Analyze → Plan → Transform → Validate workflow, testing strategies, and common pitfalls, see the meta-skill first.

This Skill Adds

  • Type mappings: Elm types → Roc types
  • Idiom translations: Elm patterns → idiomatic Roc
  • Architecture patterns: The Elm Architecture (TEA) → Platform model
  • Effect system: Cmd/Sub → Task
  • Error handling: Result types (REVERSED parameter order!)
  • Platform shift: Frontend-specific → General-purpose

This Skill Does NOT Cover

  • General conversion methodology - see meta-convert-dev
  • Elm language fundamentals - see lang-elm-dev
  • Roc language fundamentals - see lang-roc-dev
  • Browser-specific Elm code - Roc doesn't have DOM access

Quick Reference

| Elm | Roc | Notes | |-----|-----|-------| | String | Str | Direct mapping | | Int | I64 or U64 | Choose signed/unsigned based on domain | | Float | F64 | Direct mapping | | Bool | Bool | Same with capitalization | | List a | List a | Same syntax and operations | | { field : Type } | { field : Type } | Records are nearly identical | | type Custom = Tag1 \| Tag2 | [Tag1, Tag2] | Custom types → Tag unions | | Result err ok | Result ok err | REVERSED parameter order! | | Maybe a | [Some a, None] | Optional values | | Cmd Msg or Task err a | Task ok err | Effect systems differ | | case x of | when x is | Pattern matching syntax | | Task.perform | ! suffix operator | Explicit handling → Bang operator |


🚨 CRITICAL GOTCHA: Result Type Parameter Order

This is the most important thing to remember when converting Elm to Roc:

-- Elm: Result error ok
divide : Int -> Int -> Result String Int
# Roc: Result ok err (REVERSED!)
divide : I64, I64 -> Result I64 Str

Why This Matters

The parameter order is completely reversed between Elm and Roc:

  • Elm: Result error ok - Error type first, success type second
  • Roc: Result ok err - Success type first, error type second

This affects:

  • Type signatures
  • Type annotations
  • Generic type parameters
  • Error handling patterns

Always Remember

When you see Elm's Result String User, it becomes Roc's Result User Str.

Wrong: Result Str User (copying Elm order) ✓ Correct: Result User Str (reversed order)


Architectural Paradigm Shift

From The Elm Architecture to Platform Model

| Aspect | Elm TEA | Roc Platform Model | |--------|---------|-------------------| | Target | Browser frontend only | Any platform (CLI, web, native) | | Effects | Runtime-managed Cmd/Sub | Platform-provided Task | | Entry point | main : Program () Model Msg | main : Task {} [] | | State | Explicit Model | Implicit in Task chain | | Updates | update : Msg → Model → (Model, Cmd Msg) | Task composition | | I/O | Browser.* modules only | Platform exposes (File, Http, etc.) |

Elm TEA Application

module Main exposing (main)

import Browser
import Html exposing (Html, div, input, text)
import Html.Events exposing (onInput)
import Html.Attributes exposing (placeholder, value)

-- MODEL
type alias Model =
    { name : String
    , greeting : String
    }

init : () -> ( Model, Cmd Msg )
init _ =
    ( { name = "", greeting = "Hello, World!" }, Cmd.none )

-- UPDATE
type Msg
    = NameChanged String

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        NameChanged newName ->
            ( { model
                | name = newName
                , greeting = "Hello, " ++ newName ++ "!"
              }
            , Cmd.none
            )

-- VIEW
view : Model -> Html Msg
view model =
    div []
        [ div [] [ text model.greeting ]
        , input
            [ placeholder "Enter your name"
            , value model.name
            , onInput NameChanged
            ]
            []
        ]

-- MAIN
main : Program () Model Msg
main =
    Browser.element
        { init = init
        , update = update
        , view = view
        , subscriptions = \_ -> Sub.none
        }

Roc Platform Equivalent

app [main] {
    pf: platform "https://github.com/roc-lang/basic-cli/releases/download/0.10.0/vNe6s9hWzoTZtFmNkvEICPErI9ptji_ySjicO6CkucY.tar.br"
}

import pf.Stdout
import pf.Stdin
import pf.Task exposing [Task]

main : Task {} []
main =
    Stdout.line! "Hello, World!"
    Stdout.line! "Enter your name:"
    name = Stdin.line!
    Stdout.line! "Hello, \(name)!"

Key shift: Elm's declarative Model-Update-View loop becomes Roc's imperative Task chain.

Note: The Roc version is CLI-based because Roc doesn't target the browser. For equivalent browser functionality, you'd need a Roc web platform (still in development).


Type System Mapping

Primitive Types

| Elm | Roc | Notes | |-----|-----|-------| | True / False | Bool.true / Bool.false | Capitalization differs | | 42 | 42 | Integer literals | | 3.14 | 3.14 | Float literals | | "text" | "text" | String literals | | Int | I64 or U64 | Elm has arbitrary precision, Roc has sized types | | Float | F64 or F32 | Elm has single Float, Roc has sized types | | String | Str | Direct mapping | | Char | U32 | Roc treats chars as Unicode scalar values |

Collection Types

| Elm | Roc | Notes | |-----|-----|-------| | List a | List a | Identical syntax and semantics | | Dict comparable v | Dict k v | Roc requires k to implement Hash & Eq | | Set comparable | Set a | Roc requires a to implement Hash & Eq | | ( a, b ) | (a, b) | Tuples (Roc supports arbitrary tuple sizes) | | Array a | List a | Elm's Array → Roc's List (Roc optimizes internally) |

Record Types

| Elm | Roc | Notes | |-----|-----|-------| | { name : String, age : Int } | { name : Str, age : U32 } | Nearly identical, just type name differences | | { user \| age = 31 } | { user & age: 31 } | Record update syntax differs (| vs &, = vs :) | | { name, age } = user | { name, age } = user | Destructuring identical | | user.name | user.name | Field access identical |

Custom Types to Tag Unions

Elm:

-- Named custom type (nominal)
type Color
    = Red
    | Green
    | Blue
    | Custom Int Int Int

type alias RGB =
    { r : Int, g : Int, b : Int }

Roc:

# Structural tag union
Color : [Red, Green, Blue, Custom(U8, U8, U8)]

# Record type alias
RGB : { r : U8, g : U8, b : U8 }

Key differences:

  • Elm requires explicit type declaration
  • Roc uses structural types (no declaration needed)
  • Elm uses type constructors with |
  • Roc uses tag union syntax with []

Optional Values

Elm:

-- Built-in Maybe type
email : Maybe String
email = Just "alice@example.com"

-- Pattern match
emailText : String
emailText =
    case email of
        Just addr ->
            addr
        Nothing ->
            "no email"

-- Helper functions
emailOrDefault : String
emailOrDefault =
    Maybe.withDefault "no email" email

Roc:

# Inline tag union (no built-in Maybe)
email : [Some Str, None]
email = Some("alice@example.com")

# Pattern match
emailText : Str
emailText =
    when email is
        Some(addr) -> addr
        None -> "no email"

# Manual helper or use Result

Translation:

  • Maybe a[Some a, None]
  • Just valueSome(value)
  • NothingNone

Result Type (Parameter Order Reversed!)

Elm:

-- Result error ok
divide : Int -> Int -> Result String Int
divide a b =
    if b == 0 then
        Err "Division by zero"
    else
        Ok (a // b)

Roc:

# Result ok err (REVERSED!)
divide : I64, I64 -> Result I64 Str
divide = \a, b ->
    if b == 0 then
        Err("Division by zero")
    else
        Ok(a // b)

CRITICAL:

  • Elm's Result error ok has error first
  • Roc's Result ok err has success first
  • Always reverse the parameter order when converting

Idiom Translation

1. Pattern Matching: case → when

Elm:

classify : Int -> String
classify n =
    case n of
        0 ->
            "zero"

        x ->
            if x < 0 then
                "negative"
            else
                "positive"

Roc:

classify : I64 -> Str
classify = \n ->
    when n is
        0 -> "zero"
        x if x < 0 -> "negative"
        _ -> "positive"

Why this translation:

  • Elm's case becomes Roc's when
  • Elm uses if expressions in branches, Roc has guard clauses (if after pattern)
  • Roc allows inline guards which are more concise

2. List Processing

Elm:

doubled : List Int
doubled =
    List.map (\x -> x * 2) [ 1, 2, 3, 4, 5 ]

-- Pipeline style
result : Int
result =
    [ 1, 2, 3, 4, 5 ]
        |> List.map (\x -> x * 2)
        |> List.filter (\x -> x > 5)
        |> List.foldl (+) 0

Roc:

doubled : List I64
doubled = List.map([1, 2, 3, 4, 5], \x -> x * 2)

# Pipeline style (same!)
result : I64
result = [1, 2, 3, 4, 5]
    |> List.map(\x -> x * 2)
    |> List.keepIf(\x -> x > 5)
    |> List.walk(0, Num.add)

Why this translation:

  • List.filterList.keepIf (different name, same semantics)
  • List.foldl / List.foldrList.walk (different name)
  • Pipeline operator |> is identical
  • Roc supports both List.map(list, fn) and List.map list fn syntax

3. Record Updates

Elm:

user =
    { name = "Alice", age = 30 }

olderUser =
    { user | age = 31 }

-- Multiple fields
updatedUser =
    { user
        | age = 31
        , name = "Alice Smith"
    }

Roc:

user = { name: "Alice", age: 30 }
olderUser = { user & age: 31 }

# Multiple fields
updatedUser = { user &
    age: 31,
    name: "Alice Smith"
}

Why this translation:

  • Elm uses | for updates, Roc uses &
  • Elm uses = for field assignment, Roc uses :
  • Syntax is almost identical otherwise

4. Cmd/Task → Task

Elm:

type Msg
    = GotData (Result Http.Error String)

fetchData : Cmd Msg
fetchData =
    Http.get
        { url = "https://api.example.com/data"
        , expect = Http.expectString GotData
        }

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        FetchData ->
            ( model, fetchData )

        GotData result ->
            case result of
                Ok data ->
                    ( { model | data = String.toUpper data }
                    , Cmd.none
                    )

                Err _ ->
                    ( { model | error = Just "Failed" }
                    , Cmd.none
                    )

Roc:

main : Task {} []
main =
    data = Http.get!("https://api.example.com/data")
    processed = Str.toUpper(data)
    Task.ok({})

Why this translation:

  • Elm's Cmd with Msg handling becomes Roc's direct Task chaining
  • Elm's Task.perform becomes Roc's ! operator
  • Elm's event-driven model becomes Roc's sequential execution
  • No Model or Msg types needed in Roc for simple cases

5. Error Propagation

Elm:

calculate : Int -> Int -> Int -> Result String Int
calculate a b c =
    divide a b
        |> Result.andThen (\x -> divide x c)

-- Or with explicit pattern matching
calculateExplicit : Int -> Int -> Int -> Result String Int
calculateExplicit a b c =
    case divide a b of
        Err e ->
            Err e
        Ok x ->
            case divide x c of
                Err e ->
                    Err e
                Ok y ->
                    Ok y

Roc:

# Roc: Result ok err (reversed params!)
calculate : I64, I64, I64 -> Result I64 Str
calculate = \a, b, c ->
    x = divide!(a, b)  # Returns early on Err
    y = divide!(x, c)  # Returns early on Err
    Ok(y)

Why this translation:

  • Elm's Result.andThen becomes Roc's ! operator
  • Roc's ! provides automatic early return on error
  • Much more concise than Elm's explicit chaining
  • Remember to reverse Result type parameters!

6. Opaque Types

Elm:

-- Elm uses module visibility for opacity
module Age exposing (Age, create, toInt)

type Age
    = Age Int

create : Int -> Maybe Age
create n =
    if n >= 0 && n < 150 then
        Just (Age n)
    else
        Nothing

toInt : Age -> Int
toInt (Age n) =
    n

-- Constructor Age is NOT exposed, only create function

Roc:

interface Age
    exposes [Age, create, toU32]
    imports []

# Opaque type
Age := U32

create : U32 -> Result Age [InvalidAge]
create = \n ->
    if n >= 0 && n < 150 then
        Ok(@Age(n))
    else
        Err(InvalidAge)

toU32 : Age -> U32
toU32 = \@Age(n) -> n

Why this translation:

  • Elm uses pattern matching for unwrapping, Roc uses @ syntax
  • Both achieve opacity through module exports
  • Roc's @Age(n) wrapping is more explicit than Elm's Age n
  • Roc uses Result for validation, Elm uses Maybe (different conventions)

Paradigm Translation: TEA → Platform Model

Mental Model Shift

| Elm Concept | Roc Approach | Key Insight | |-------------|--------------|-------------| | Model-Update-View loop | Task chain (sequential) | Declarative → Imperative | | Browser provides events | Platform provides I/O | Browser → CLI/Native | | main returns Program | main returns Task | Pure → Effect | | Cmd issued, Msg received | Tasks compose with ! | Indirect → Direct |

Effect System Comparison

| Elm Model | Roc Model | Conceptual Translation | |-----------|-----------|------------------------| | Runtime handles Cmd/Sub | Platform handles Tasks | Both managed by runtime | | Asynchronous with Msg | Sequential with ! | Event-driven → Chain | | Cmd.none / new Cmd | Task.ok/Task.err | Side effect → Return value |

Example: HTTP Fetch

Elm (TEA):

type alias Model =
    { users : RemoteData Http.Error (List User)
    }

type Msg
    = FetchUsers
    | GotUsers (Result Http.Error (List User))

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        FetchUsers ->
            ( { model | users = Loading }
            , Http.get
                { url = "/api/users"
                , expect = Http.expectJson GotUsers usersDecoder
                }
            )

        GotUsers result ->
            case result of
                Ok users ->
                    ( { model | users = Success users }, Cmd.none )

                Err error ->
                    ( { model | users = Failure error }, Cmd.none )

Roc (Platform):

main : Task {} []
main =
    users = Http.get!("/api/users")
    decoded = Decode.fromBytes!(users, usersDecoder)
    Stdout.line!("Fetched \(List.len(decoded) |> Num.toStr) users")

Key differences:

  • Elm models loading states explicitly
  • Roc handles success/error sequentially
  • Elm's async becomes Roc's sequential (platform handles concurrency)
  • No Model or Msg types needed in Roc

Error Handling

Elm Result → Roc Result

Key Difference: Parameter order is reversed!

-- Elm: Result err ok
parseAge : String -> Result String Int
parseAge str =
    case String.toInt str of
        Just age ->
            if age >= 0 then
                Ok age
            else
                Err "Negative age"

        Nothing ->
            Err "Not a number"
# Roc: Result ok err (REVERSED!)
parseAge : Str -> Result U32 Str
parseAge = \str ->
    when Str.toU32(str) is
        Ok(age) if age >= 0 -> Ok(age)
        Ok(_) -> Err("Negative age")
        Err(_) -> Err("Not a number")

Error Type Modeling

Elm uses named custom types:

type FetchError
    = NetworkError Http.Error
    | NotFound
    | Unauthorized

fetchUser : Int -> Task FetchError User

Roc uses inline tag unions:

fetchUser : U64 -> Task User [NetworkError, NotFound, Unauthorized]

Translation:

  • Elm's named error types → Roc's inline tag unions
  • Same expressiveness, less ceremony
  • Roc is structural, Elm is nominal

Effect System Translation

Cmd in Elm vs Task in Roc

Elm Cmd (event-driven):

type Msg
    = GotData (Result Http.Error String)

fetchData : Cmd Msg
fetchData =
    Http.get
        { url = "https://api.example.com/data"
        , expect = Http.expectString GotData
        }

-- Must handle result in update function
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        GotData result ->
            -- Handle result here
            ...

Roc Task (sequential):

fetchData : Task Str []
fetchData =
    Http.get!("https://api.example.com/data")

Why this translation:

  • Elm's Cmd is fire-and-forget, result comes via Msg
  • Roc's Task chains sequentially with !
  • Elm separates effect from handling, Roc combines them

Sub in Elm vs Task in Roc

Elm Subscriptions:

subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.batch
        [ Time.every 1000 Tick
        , Browser.Events.onResize WindowResized
        ]

Roc approach: Roc doesn't have built-in subscriptions. Platforms may provide equivalent mechanisms through Task-based polling or event streams, but this is platform-specific.

For periodic tasks, you'd typically use platform-specific APIs or structure your main Task to loop.


Module System Translation

Elm Modules → Roc Interfaces

Elm:

module User exposing (User, create, getName, getAge)

type alias User =
    { name : String
    , age : Int
    }

create : String -> Int -> User
create name age =
    { name = name, age = age }

getName : User -> String
getName user =
    user.name

getAge : User -> Int
getAge user =
    user.age

Roc:

interface User
    exposes [User, create, getName, getAge]
    imports []

User : {
    name : Str,
    age : U32,
}

create : Str, U32 -> User
create = \name, age ->
    { name, age }

getName : User -> Str
getName = \user -> user.name

getAge : User -> U32
getAge = \user -> user.age

Translation:

  • moduleinterface
  • exposingexposes
  • type alias → type annotation
  • Same visibility model (only exposed items are public)

Import Patterns

Elm:

import Dict
import Dict exposing (Dict)
import List exposing (map, filter)
import Maybe exposing (Maybe(..))
import Html as H
import Html.Events as Events

Roc:

import Dict
import Dict exposing [Dict]
import List exposing [map, keepIf]
import pf.Stdout
import pf.Task exposing [Task]

# Note: Roc doesn't have import aliasing yet
# Must use full qualified names

Translation:

  • exposingexposing
  • Parentheses () → Brackets []
  • Elm's import as → Not yet available in Roc

Common Pitfalls

  1. Result parameter order reversal (MOST CRITICAL)

    • Elm: Result err ok
    • Roc: Result ok err
    • Always reverse parameters when converting Result types
    • Double-check every Result type signature!
  2. Record update syntax

    • Elm: { record | field = value }
    • Roc: { record & field: value }
    • Don't mix up |/& and =/:
  3. Case vs When syntax

    • Elm: case x of
    • Roc: when x is
    • Remember is not of
  4. Platform target mismatch

    • Elm targets browser only (DOM, HTML, CSS)
    • Roc is platform-agnostic (CLI, native, potentially web)
    • Browser-specific Elm code needs redesign
  5. Bang operator vs explicit Task

    • Elm: No ! operator, use Task.perform or Cmd
    • Roc: value = task! for sequential execution
    • Much more concise in Roc
  6. Capitalization

    • Elm: True, False
    • Roc: Bool.true, Bool.false
    • Watch for True/False differences
  7. Function types

    • Elm: a -> b -> c (curried)
    • Roc: a, b -> c (comma-separated params)
    • Roc allows both, but commas are clearer
  8. Maybe vs tag union

    • Elm: Built-in Maybe a with Just/Nothing
    • Roc: Use [Some a, None] (no built-in Maybe)
    • Must define tag union explicitly
  9. List function names

    • Elm: List.filter, List.foldl, List.foldr
    • Roc: List.keepIf, List.walk
    • Same concepts, different names
  10. String interpolation

    • Elm: "Hello, " ++ name ++ "!"
    • Roc: "Hello, \(name)!"
    • Roc has built-in string interpolation

Tooling

| Tool | Elm | Roc | Notes | |------|-----|-----|-------| | Formatter | elm-format | roc format | Both enforce standard style | | REPL | elm repl | roc repl | Both support interactive testing | | Test | elm-test | roc test | Different syntax (case vs expect) | | Build | elm make | roc build | Elm → JavaScript, Roc → native | | Package manager | elm install | Platform URLs | Roc uses URL-based dependencies | | Linter | elm-review | N/A | Elm has rich linting, Roc doesn't yet |


Examples

Example 1: Simple - Type and Function Translation

Before (Elm):

type alias User =
    { name : String
    , age : Int
    }

greet : User -> String
greet user =
    "Hello, " ++ user.name ++ "! You are " ++ String.fromInt user.age ++ " years old."

-- Test
import Test exposing (test)
import Expect

suite =
    test "greet formats message correctly" <|
        \_ ->
            greet { name = "Alice", age = 30 }
                |> Expect.equal "Hello, Alice! You are 30 years old."

After (Roc):

User : { name : Str, age : U32 }

greet : User -> Str
greet = \user ->
    "Hello, \(user.name)! You are \(Num.toStr(user.age)) years old."

expect greet({ name: "Alice", age: 30 }) == "Hello, Alice! You are 30 years old."

Key changes:

  • StringStr, IntU32
  • String concatenation ++ → interpolation \(...)
  • String.fromIntNum.toStr
  • Elm's separate test file → inline expect
  • type alias → type annotation

Example 2: Medium - Custom Types and Pattern Matching

Before (Elm):

type Color
    = Red
    | Green
    | Blue
    | Custom Int Int Int

toHex : Color -> String
toHex color =
    case color of
        Red ->
            "#FF0000"

        Green ->
            "#00FF00"

        Blue ->
            "#0000FF"

        Custom r g b ->
            "#" ++ toHexByte r ++ toHexByte g ++ toHexByte b

toHexByte : Int -> String
toHexByte n =
    -- Implementation using Hex library
    String.fromInt n  -- Simplified

After (Roc):

Color : [Red, Green, Blue, Custom(U8, U8, U8)]

toHex : Color -> Str
toHex = \color ->
    when color is
        Red -> "#FF0000"
        Green -> "#00FF00"
        Blue -> "#0000FF"
        Custom(r, g, b) ->
            "#\(toHexByte(r))\(toHexByte(g))\(toHexByte(b))"

toHexByte : U8 -> Str
toHexByte = \n ->
    # Implementation
    Num.toStr(n)  # Simplified

Key changes:

  • Named type declaration → Structural tag union
  • case x ofwhen x is
  • IntU8 (Roc has sized integers)
  • String concatenation → interpolation
  • Constructor Custom r g bCustom(r, g, b)

Example 3: Complex - TEA to Platform Model

Before (Elm):

module Main exposing (main)

import Browser
import Html exposing (Html, div, text, button)
import Html.Events exposing (onClick)
import Http
import Json.Decode as Decode exposing (Decoder)

-- MODEL

type alias User =
    { id : Int
    , name : String
    , email : String
    }

type RemoteData e a
    = NotAsked
    | Loading
    | Success a
    | Failure e

type alias Model =
    { user : RemoteData Http.Error User
    }

init : () -> ( Model, Cmd Msg )
init _ =
    ( { user = NotAsked }, Cmd.none )

-- UPDATE

type Msg
    = FetchUser
    | GotUser (Result Http.Error User)

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        FetchUser ->
            ( { model | user = Loading }
            , fetchUser 1
            )

        GotUser result ->
            case result of
                Ok user ->
                    ( { model | user = Success user }
                    , Cmd.none
                    )

                Err error ->
                    ( { model | user = Failure error }
                    , Cmd.none
                    )

-- HTTP

fetchUser : Int -> Cmd Msg
fetchUser userId =
    Http.get
        { url = "https://api.example.com/users/" ++ String.fromInt userId
        , expect = Http.expectJson GotUser userDecoder
        }

userDecoder : Decoder User
userDecoder =
    Decode.map3 User
        (Decode.field "id" Decode.int)
        (Decode.field "name" Decode.string)
        (Decode.field "email" Decode.string)

-- VIEW

view : Model -> Html Msg
view model =
    div []
        [ case model.user of
            NotAsked ->
                button [ onClick FetchUser ] [ text "Fetch User" ]

            Loading ->
                text "Loading..."

            Success user ->
                div []
                    [ text ("User: " ++ user.name ++ " (" ++ user.email ++ ")")
                    ]

            Failure error ->
                text ("Error: " ++ httpErrorToString error)
        ]

httpErrorToString : Http.Error -> String
httpErrorToString error =
    case error of
        Http.BadUrl url ->
            "Bad URL: " ++ url

        Http.Timeout ->
            "Timeout"

        Http.NetworkError ->
            "Network error"

        Http.BadStatus status ->
            "Bad status: " ++ String.fromInt status

        Http.BadBody body ->
            "Bad body: " ++ body

-- MAIN

main : Program () Model Msg
main =
    Browser.element
        { init = init
        , update = update
        , view = view
        , subscriptions = \_ -> Sub.none
        }

After (Roc):

app [main] {
    pf: platform "https://github.com/roc-lang/basic-cli/releases/download/0.10.0/vNe6s9hWzoTZtFmNkvEICPErI9ptji_ySjicO6CkucY.tar.br"
}

import pf.Http
import pf.Stdout
import pf.Task exposing [Task]
import json.Decode

# Note: Result type parameters REVERSED!
# Elm: Result Http.Error User
# Roc: Result User [HttpErr]

User : { id : U64, name : Str, email : Str }

fetchUser : U64 -> Task User [HttpErr, DecodeErr]
fetchUser = \userId ->
    url = "https://api.example.com/users/\(Num.toStr(userId))"
    response = Http.get!(url)

    when Decode.fromBytes(response.body, userDecoder) is
        Ok(user) -> Task.ok(user)
        Err(err) -> Task.err(DecodeErr)

userDecoder : Decode.Decoder User
userDecoder =
    Decode.record(\field ->
        {
            id: field.required("id", Decode.u64),
            name: field.required("name", Decode.str),
            email: field.required("email", Decode.str),
        }
    )

main : Task {} []
main =
    when fetchUser(1) is
        Ok(user) ->
            Stdout.line!("User: \(user.name) (\(user.email))")

        Err(HttpErr) ->
            Stdout.line!("HTTP error occurred")

        Err(DecodeErr) ->
            Stdout.line!("Failed to decode user")

Key changes:

  • Elm's TEA (Model-Update-View) → Roc's Task chain
  • Elm's Cmd Msg handling → Roc's ! operator
  • Elm's HTML view → Roc's CLI output
  • Elm's loading states → Roc's direct execution
  • Elm's Result Http.Error User → Roc's Result User [HttpErr, DecodeErr] (reversed params!)
  • No Model, Msg, or update function needed
  • Direct error handling with pattern matching

Testing Translation

Elm's elm-test → Roc's expect

Elm:

-- tests/UserTests.elm
module UserTests exposing (suite)

import Test exposing (Test, describe, test)
import Expect
import User

suite : Test
suite =
    describe "User module"
        [ describe "greet"
            [ test "formats greeting correctly" <|
                \_ ->
                    User.greet { name = "Alice", age = 30 }
                        |> Expect.equal "Hello, Alice! You are 30 years old."

            , test "handles young age" <|
                \_ ->
                    User.greet { name = "Bob", age = 5 }
                        |> Expect.equal "Hello, Bob! You are 5 years old."
            ]
        ]

Roc:

# User.roc
interface User
    exposes [User, greet]
    imports []

User : { name : Str, age : U32 }

greet : User -> Str
greet = \user ->
    "Hello, \(user.name)! You are \(Num.toStr(user.age)) years old."

# Inline tests
expect greet({ name: "Alice", age: 30 }) == "Hello, Alice! You are 30 years old."
expect greet({ name: "Bob", age: 5 }) == "Hello, Bob! You are 5 years old."

Translation:

  • Elm's separate test files → Roc's inline expect
  • Elm's describe and test → Roc's flat expect statements
  • Elm's Expect.equal → Roc's == operator
  • Run with elm-test → Run with roc test

See Also

For more examples and patterns, see:

  • meta-convert-dev - Foundational patterns with cross-language examples
  • convert-elm-haskell - Similar functional language conversion patterns
  • lang-elm-dev - Elm development patterns
  • lang-roc-dev - Roc development patterns

Cross-cutting pattern skills:

  • patterns-concurrency-dev - Cmd/Sub vs Task comparison
  • patterns-serialization-dev - JSON encoding/decoding across languages
  • patterns-metaprogramming-dev - Why both languages avoid metaprogramming