| name | convert-roc-elm |
| description | Convert Roc code to idiomatic Elm. Use when migrating Roc applications to Elm frontend code, translating platform-agnostic Roc to browser-based Elm, or refactoring Roc CLI tools to Elm web applications. Extends meta-convert-dev with Roc-to-Elm specific patterns. |
Convert Roc to Elm
Convert Roc code to idiomatic Elm. This skill extends meta-convert-dev with Roc-to-Elm specific type mappings, idiom translations, and architectural patterns for moving from platform-agnostic Roc to browser-based Elm applications.
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: Roc types → Elm types
- Idiom translations: Roc patterns → idiomatic Elm
- Architecture patterns: Platform model → The Elm Architecture (TEA)
- Effect system: Task → Cmd/Sub
- Error handling: Result types (similar but different conventions)
- Platform shift: General-purpose → Frontend-specific
This Skill Does NOT Cover
- General conversion methodology - see
meta-convert-dev - Roc language fundamentals - see
lang-roc-dev - Elm language fundamentals - see
lang-elm-dev - Reverse conversion (Elm → Roc) - see
convert-elm-roc - Backend-specific Roc code - Elm is frontend-only
Quick Reference
| Roc | Elm | Notes |
|---|---|---|
Str |
String |
Direct mapping |
I64, U64 |
Int |
Elm has arbitrary precision integers |
F64 |
Float |
Direct mapping |
Bool |
Bool |
Direct mapping with capitalization |
List a |
List a |
Same syntax and operations |
{ field : Type } |
{ field : Type } |
Records are nearly identical |
[Tag1, Tag2] |
type Custom = Tag1 | Tag2 |
Tag unions → Custom types |
Result a e |
Result e a |
Reversed parameter order! |
[Some a, None] |
Maybe a |
Optional values |
Task a err |
Cmd Msg or Task Never a |
Effect systems differ |
when x is |
case x of |
Pattern matching syntax |
! suffix operator |
Task.perform |
Bang operator → explicit Task handling |
Architectural Paradigm Shift
From Platform Model to The Elm Architecture
| Aspect | Roc Platform Model | Elm TEA |
|---|---|---|
| Target | Any platform (CLI, web, native) | Browser frontend only |
| Effects | Platform-provided Task | Runtime-managed Cmd/Sub |
| Entry point | main : Task {} [] |
main : Program () Model Msg |
| State | Implicit in Task chain | Explicit Model |
| Updates | Task composition | update : Msg → Model → (Model, Cmd Msg) |
| I/O | Platform exposes (File, Http, etc.) | Browser.* modules only |
Roc Platform Application
app [main] {
pf: platform "https://github.com/roc-lang/basic-cli/..."
}
import pf.Stdout
import pf.Task exposing [Task]
main : Task {} []
main =
Stdout.line! "Hello, World!"
name = Stdin.line!
Stdout.line! "Hello, \(name)!"
Elm Equivalent Using TEA
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
}
Key shift: Roc's imperative Task chain becomes Elm's declarative Model-Update-View cycle.
Type System Mapping
Primitive Types
| Roc | Elm | Notes |
|---|---|---|
Bool.true / Bool.false |
True / False |
Capitalization differs |
42 |
42 |
Integer literals (Elm has arbitrary precision) |
3.14 |
3.14 |
Float literals |
"text" |
"text" |
String literals (Roc uses Str, Elm uses String) |
I8, I16, I32, I64, I128 |
Int |
Elm has single Int type (arbitrary precision) |
U8, U16, U32, U64, U128 |
Int |
Same - map to Int |
F32, F64 |
Float |
Elm has single Float type |
Num a |
number |
Flexible number type (inferred) |
Collection Types
| Roc | Elm | Notes |
|---|---|---|
List a |
List a |
Identical syntax and semantics |
Dict k v |
Dict k v |
Same interface, import from Dict module |
Set a |
Set a |
Same interface, import from Set module |
(a, b) |
( a, b ) |
Tuples (Elm supports up to 3-tuples idiomatically) |
Record Types
| Roc | Elm | Notes |
|---|---|---|
{ name : Str, age : U32 } |
{ name : String, age : Int } |
Nearly identical, just type name differences |
{ user & age: 31 } |
{ user | age = 31 } |
Record update syntax differs (& vs |) |
{ name, age } = user |
{ name, age } = user |
Destructuring identical |
user.name |
user.name |
Field access identical |
Tag Unions to Custom Types
Roc:
# Structural tag union
Color : [Red, Green, Blue, Custom(U8, U8, U8)]
handleColor : Color -> Str
handleColor = \color ->
when color is
Red -> "red"
Green -> "green"
Blue -> "blue"
Custom(r, g, b) -> "rgb(\(Num.toStr(r)), ...)"
Elm:
-- Named custom type (nominal)
type Color
= Red
| Green
| Blue
| Custom Int Int Int
handleColor : Color -> String
handleColor color =
case color of
Red ->
"red"
Green ->
"green"
Blue ->
"blue"
Custom r g b ->
"rgb(" ++ String.fromInt r ++ ", ...)"
Key differences:
- Roc uses structural types (no declaration needed)
- Elm requires explicit
typedeclaration - Roc uses lowercase for type variables in tag payloads
- Elm uses type constructors with capital letters
Optional Values
Roc:
# Inline tag union
email : [Some Str, None]
email = Some("alice@example.com")
# Pattern match
emailText = when email is
Some(addr) -> addr
None -> "no email"
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
Translation:
[Some a, None]→Maybe aSome(value)→Just valueNone→Nothing
Result Type (Parameter Order Reversed!)
Roc:
# Result ok err
divide : I64, I64 -> Result I64 [DivByZero]
divide = \a, b ->
if b == 0 then
Err(DivByZero)
else
Ok(a // b)
Elm:
-- Result error ok (REVERSED!)
divide : Int -> Int -> Result String Int
divide a b =
if b == 0 then
Err "Division by zero"
else
Ok (a // b)
CRITICAL: Roc's Result ok err becomes Elm's Result error ok - parameters are reversed!
Idiom Translation
1. Pattern Matching: when → case
Roc:
classify : I64 -> Str
classify = \n ->
when n is
0 -> "zero"
x if x < 0 -> "negative"
_ -> "positive"
Elm:
classify : Int -> String
classify n =
case n of
0 ->
"zero"
x ->
if x < 0 then
"negative"
else
"positive"
Why this translation:
- Roc's
whenbecomes Elm'scase - Roc has guard clauses (
ifafter pattern), Elm usesifexpressions in branches - Elm requires explicit
->and indentation
2. List Processing
Roc:
doubled : List I64
doubled = List.map([1, 2, 3, 4, 5], \x -> x * 2)
# Pipeline style
result = [1, 2, 3, 4, 5]
|> List.map(\x -> x * 2)
|> List.keepIf(\x -> x > 5)
|> List.walk(0, Num.add)
Elm:
doubled : List Int
doubled =
List.map (\x -> x * 2) [ 1, 2, 3, 4, 5 ]
-- Pipeline style (same!)
result : Int
result =
[ 1, 2, 3, 4, 5 ]
|> List.map (\x -> x * 2)
|> List.filter (\x -> x > 5)
|> List.foldl (+) 0
Why this translation:
List.keepIf→List.filter(different name)List.walk→List.foldlorList.foldr(different name)- Pipeline operator
|>is identical - Elm uses function-first, args-last (currying)
3. Record Updates
Roc:
user = { name: "Alice", age: 30 }
olderUser = { user & age: 31 }
# Multiple fields
updatedUser = { user &
age: 31,
name: "Alice Smith"
}
Elm:
user =
{ name = "Alice", age = 30 }
olderUser =
{ user | age = 31 }
-- Multiple fields
updatedUser =
{ user
| age = 31
, name = "Alice Smith"
}
Why this translation:
- Roc uses
&for updates, Elm uses| - Roc uses
:for field assignment, Elm uses= - Syntax is almost identical otherwise
4. Task-Based Effects → Cmd/Task
Roc:
main : Task {} []
main =
content = File.readUtf8!("input.txt")
processed = String.toUpper(content)
File.writeUtf8!("output.txt", processed)
Stdout.line!("Done!")
Elm:
-- Elm doesn't have file access (browser only)
-- This example shows HTTP instead
type Msg
= GotData (Result Http.Error String)
| DataProcessed
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
FetchData ->
( { model | loading = True }
, Http.get
{ url = "/api/data"
, expect = Http.expectString GotData
}
)
GotData result ->
case result of
Ok content ->
( { model
| data = String.toUpper content
, loading = False
}
, Cmd.none
)
Err _ ->
( { model | error = Just "Failed to load" }
, Cmd.none
)
Why this translation:
- Roc's
!suffix operator becomes explicit Cmd in Elm - Roc chains Tasks sequentially; Elm uses Model updates
- File I/O doesn't exist in Elm (browser sandbox)
- Must model async operations as Msg and handle in update
5. Error Propagation
Roc:
calculate : I64, I64, I64 -> Result I64 [DivByZero]
calculate = \a, b, c ->
x = divide!(a, b) # Returns early on Err
y = divide!(x, c) # Returns early on Err
Ok(y)
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
Why this translation:
- Roc's
!operator doesn't exist in Elm - Use
Result.andThenfor chaining (monadic bind) - Or use explicit
caseexpressions
6. Opaque Types
Roc:
Age := U32
createAge : U32 -> Age
createAge = \n -> @Age(n)
getAge : Age -> U32
getAge = \@Age(n) -> n
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
Why this translation:
- Roc uses
@unwrapping syntax, Elm uses pattern matching - Elm achieves opacity through module exports (
Agetype exposed, constructor hidden) - Elm typically adds validation in smart constructors
Paradigm Translation: Platform Model → TEA
Mental Model Shift
| Roc Concept | Elm Approach | Key Insight |
|---|---|---|
| Task chain (sequential) | Model-Update-View loop | Imperative → Declarative |
| Platform provides I/O | Browser provides events | CLI/Native → Browser |
main returns Task |
main returns Program |
Effect → Pure |
Tasks compose with ! |
Cmd issued, Msg received | Direct → Indirect |
Concurrency Mental Model
| Roc Model | Elm Model | Conceptual Translation |
|---|---|---|
| Platform handles Tasks | Runtime handles Cmd/Sub | Both managed by runtime |
Sequential with ! |
Asynchronous with Msg | Chain → Event-driven |
| Task.ok/Task.err | Cmd.none / new Cmd | Return value → Side effect |
Error Handling
Roc Result → Elm Result
Key Difference: Parameter order is reversed!
-- Roc: Result ok err
parseAge : Str -> Result U32 [ParseError Str, NegativeAge]
parseAge = \str ->
when Str.toU32(str) is
Ok(age) if age >= 0 -> Ok(age)
Ok(_) -> Err(NegativeAge)
Err(_) -> Err(ParseError("Not a number"))
-- Elm: Result err ok (REVERSED!)
type ParseError
= NotANumber
| NegativeAge
parseAge : String -> Result ParseError Int
parseAge str =
case String.toInt str of
Just age ->
if age >= 0 then
Ok age
else
Err NegativeAge
Nothing ->
Err NotANumber
Error Type Modeling
Roc uses inline tag unions:
fetchUser : U64 -> Task User [NetworkError, NotFound, Unauthorized]
Elm uses named custom types:
type FetchError
= NetworkError Http.Error
| NotFound
| Unauthorized
fetchUser : Int -> Task FetchError User
Effect System Translation
Task in Roc vs Task/Cmd in Elm
Roc Task:
# Platform-provided, sequential execution
fetchAndProcess : Task Str []
fetchAndProcess =
data = Http.get!("https://api.example.com/data")
processed = String.toUpper(data)
Task.ok(processed)
Elm Cmd (most common):
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
)
Elm Task (advanced):
import Task
-- For chaining async operations
fetchAndProcess : Task Http.Error String
fetchAndProcess =
Http.task
{ method = "GET"
, headers = []
, url = "https://api.example.com/data"
, body = Http.emptyBody
, resolver = Http.stringResolver handleResponse
, timeout = Nothing
}
|> Task.map String.toUpper
-- Convert to Cmd
performFetch : Cmd Msg
performFetch =
Task.attempt GotData fetchAndProcess
Common Pitfalls
Result parameter order reversal
- Roc:
Result ok err - Elm:
Result err ok - Always swap parameters when converting Result types
- Roc:
Record update syntax
- Roc:
{ record & field: value } - Elm:
{ record | field = value } - Don't mix up
&/|and:/=
- Roc:
Case vs When syntax
- Roc:
when x is - Elm:
case x of - Remember
ofnotis
- Roc:
Module target mismatch
- Roc CLI/native modules (File, Stdout) don't exist in Elm
- Must redesign as browser-based UI
- No file I/O, only HTTP and localStorage
Bang operator translation
- Roc:
value = task! - Elm: Must use
Task.andThenor Cmd with Msg handling - No direct equivalent to
!operator
- Roc:
Capitalization
- Roc:
Bool.true - Elm:
True - Watch for True/False vs true/false
- Roc:
Function application
- Both use space for application, but Elm heavily uses currying
- Roc:
List.map(list, fn)orList.map list fn - Elm:
List.map fn list(function first)
Tooling
| Tool | Roc | Elm | Notes |
|---|---|---|---|
| Formatter | roc format |
elm-format |
Both enforce standard style |
| REPL | roc repl |
elm repl |
Both support interactive testing |
| Test | roc test |
elm-test |
Different syntax, same concept |
| Build | roc build |
elm make |
Roc → native, Elm → JavaScript |
| Package manager | Platform URLs | elm install |
Elm has centralized package repo |
| Linter | N/A | elm-review |
Elm has rich linting ecosystem |
Examples
Example 1: Simple - Type and Function Translation
Before (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."
After (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 (in tests/ directory)
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."
Key changes:
Str→String,U32→Int- String interpolation
\(...)→ concatenation++ Num.toStr→String.fromIntexpect→ separate test file withTestmodule
Example 2: Medium - Tag Unions and Pattern Matching
Before (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 details...
After (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 or String.fromInt with base conversion
String.fromInt n -- Simplified for example
Key changes:
- Structural tag union → Named
typedeclaration when x is→case x ofU8→Int(Elm doesn't have sized integers)- String interpolation → concatenation
Example 3: Complex - Task Chain to TEA
Before (Roc):
app [main] {
pf: platform "https://github.com/roc-lang/basic-cli/..."
}
import pf.Http
import pf.Stdout
import pf.Task exposing [Task]
User : { id : U64, name : Str, email : Str }
fetchUser : U64 -> Task User [HttpError]
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(_) -> Task.err(HttpError)
main : Task {} []
main =
user = fetchUser!(1)
Stdout.line!("User: \(user.name) (\(user.email))")
After (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
}
Key changes:
- Roc's imperative Task chain → Elm's TEA (Model-Update-View)
- Roc's
!operator → Elm's Cmd and Msg handling - Roc's CLI output → Elm's HTML view
- Roc's sequential execution → Elm's event-driven updates
- Added loading states (NotAsked, Loading, Success, Failure)
- Explicit error handling in view
See Also
For more examples and patterns, see:
meta-convert-dev- Foundational patterns with cross-language examplesconvert-erlang-elm- Similar backend-to-frontend conversion patternslang-roc-dev- Roc development patternslang-elm-dev- Elm development patterns
Cross-cutting pattern skills:
patterns-concurrency-dev- Task vs Cmd/Sub comparisonpatterns-serialization-dev- JSON encoding/decoding across languages