Back to skills
extension
Category: Development & EngineeringNo API key required

convert-roc-elm

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.

personAuthor: jakexiaohubgithub

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 type declaration
  • 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 a
  • Some(value)Just value
  • NoneNothing

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 when becomes Elm's case
  • Roc has guard clauses (if after pattern), Elm uses if expressions 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.keepIfList.filter (different name)
  • List.walkList.foldl or List.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.andThen for chaining (monadic bind)
  • Or use explicit case expressions

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 (Age type 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

  1. Result parameter order reversal

    • Roc: Result ok err
    • Elm: Result err ok
    • Always swap parameters when converting Result types
  2. Record update syntax

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

    • Roc: when x is
    • Elm: case x of
    • Remember of not is
  4. 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
  5. Bang operator translation

    • Roc: value = task!
    • Elm: Must use Task.andThen or Cmd with Msg handling
    • No direct equivalent to ! operator
  6. Capitalization

    • Roc: Bool.true
    • Elm: True
    • Watch for True/False vs true/false
  7. Function application

    • Both use space for application, but Elm heavily uses currying
    • Roc: List.map(list, fn) or List.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:

  • StrString, U32Int
  • String interpolation \(...) → concatenation ++
  • Num.toStrString.fromInt
  • expect → separate test file with Test module

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 type declaration
  • when x iscase x of
  • U8Int (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 examples
  • convert-erlang-elm - Similar backend-to-frontend conversion patterns
  • lang-roc-dev - Roc development patterns
  • lang-elm-dev - Elm development patterns

Cross-cutting pattern skills:

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