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

convert-roc-haskell

Convert Roc code to idiomatic Haskell. Use when migrating Roc projects to Haskell, translating Roc patterns to idiomatic Haskell, or refactoring Roc codebases. Extends meta-convert-dev with Roc-to-Haskell specific patterns.

personAuthor: jakexiaohubgithub

Convert Roc to Haskell

Convert Roc code to idiomatic Haskell. This skill extends meta-convert-dev with Roc-to-Haskell specific type mappings, idiom translations, and tooling for migrating platform-based Roc code to pure functional Haskell.

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 → Haskell types
  • Idiom translations: Roc patterns → idiomatic Haskell
  • Error handling: Roc Result → Haskell Either/Maybe
  • Platform model: Roc applications/platforms → Haskell IO/mtl
  • Abilities: Roc abilities → Haskell type classes
  • Tag unions: Roc structural tags → Haskell algebraic data types

This Skill Does NOT Cover

  • General conversion methodology - see meta-convert-dev
  • Roc language fundamentals - see lang-roc-dev
  • Haskell language fundamentals - see lang-haskell-dev
  • Reverse conversion (Haskell → Roc) - see convert-haskell-roc
  • Platform development - Both use different models; design from scratch

Quick Reference

| Roc | Haskell | Notes | |-----|---------|-------| | Str | String or Text | Use Text for production | | U8, U16, U32, U64 | Word8, Word16, Word32, Word64 | Unsigned integers | | I8, I16, I32, I64 | Int8, Int16, Int32, Int64 | Signed integers | | F32, F64 | Float, Double | Floating point | | Bool | Bool | Direct mapping | | List a | [a] | Lists | | Dict k v | Map k v | Use Data.Map | | Set a | Set a | Use Data.Set | | Result a e | Either e a | Note reversed type params | | [Tag1, Tag2] | data X = Tag1 \| Tag2 | Sum types | | { field : Type } | data X = X { field :: Type } | Records | | Task a err | IO a or ExceptT err IO a | Effects | | where a implements Ability | (TypeClass a) => | Constraints |

When Converting Code

  1. Analyze platform boundaries - Understand where Roc platform ends and application begins
  2. Map types first - Roc's structural types need explicit Haskell ADTs
  3. Preserve semantics over syntax similarity
  4. Embrace laziness - Haskell is lazy by default; Roc is strict
  5. Handle effects properly - Roc Tasks become IO or monad transformers
  6. Test equivalence - Same inputs → same outputs for pure logic

Type System Mapping

Primitive Types

| Roc | Haskell | Notes | |-----|---------|-------| | Str | String | List of Char (less efficient) | | Str | Text | Preferred from Data.Text | | U8 | Word8 | From Data.Word | | U16 | Word16 | From Data.Word | | U32 | Word32 | From Data.Word | | U64 | Word64 | From Data.Word | | U128 | Integer | No direct u128, use arbitrary precision | | I8 | Int8 | From Data.Int | | I16 | Int16 | From Data.Int | | I32 | Int32 | From Data.Int | | I64 | Int64 | From Data.Int | | I128 | Integer | Arbitrary precision | | F32 | Float | 32-bit float | | F64 | Double | 64-bit float | | Bool | Bool | Direct mapping | | Num a | Polymorphic number | Use type classes |

Collection Types

| Roc | Haskell | Notes | |-----|---------|-------| | List a | [a] | Linked list | | List a | Vector a | For indexed access (Data.Vector) | | Dict k v | Map k v | From Data.Map | | Set a | Set a | From Data.Set |

Composite Types

| Roc | Haskell | Notes | |-----|---------|-------| | { name : Str, age : U32 } | data User = User { name :: Text, age :: Word32 } | Record syntax | | [Ok a, Err e] | Either e a | Note: type params reversed! | | [Some a, None] | Maybe a | Optional values | | [Tag1, Tag2, Tag3] | data X = Tag1 \| Tag2 \| Tag3 | Sum types | | [Tag(T)] | data X = Tag T | Tag with payload |

Function Types

| Roc | Haskell | Notes | |-----|---------|-------| | a -> b | a -> b | Simple function | | a, b -> c | a -> b -> c | Curried by default | | a -> b where a implements Eq | (Eq a) => a -> b | Type class constraint |


Idiom Translation

Pattern 1: Records and Record Updates

Roc:

user = { name: "Alice", age: 30, email: "alice@example.com" }

# Update syntax
olderUser = { user & age: 31 }

# Field access
userName = user.name

Haskell:

data User = User
    { name :: Text
    , age :: Word32
    , email :: Text
    } deriving (Show, Eq)

user = User "Alice" 30 "alice@example.com"

-- Update syntax
olderUser = user { age = 31 }

-- Field access (auto-generated accessor functions)
userName = name user

Why this translation:

  • Roc's anonymous records need named data declarations in Haskell
  • Both support record update syntax, but Haskell generates accessor functions
  • Haskell requires explicit type declarations; Roc infers structural types

Pattern 2: Tag Unions and Pattern Matching

Roc:

# Tag union
Color : [Red, Yellow, Green, Custom(U8, U8, U8)]

# Pattern matching
colorName = when color is
    Red -> "red"
    Yellow -> "yellow"
    Green -> "green"
    Custom(r, g, b) -> "rgb(\(Num.toStr(r)), \(Num.toStr(g)), \(Num.toStr(b)))"

Haskell:

-- Algebraic data type
data Color
    = Red
    | Yellow
    | Green
    | Custom Word8 Word8 Word8
    deriving (Show, Eq)

-- Pattern matching with case
colorName :: Color -> String
colorName color = case color of
    Red -> "red"
    Yellow -> "yellow"
    Green -> "green"
    Custom r g b -> "rgb(" ++ show r ++ ", " ++ show g ++ ", " ++ show b ++ ")"

-- Or with function patterns
colorName' :: Color -> String
colorName' Red = "red"
colorName' Yellow = "yellow"
colorName' Green = "green"
colorName' (Custom r g b) = "rgb(" ++ show r ++ ", " ++ show g ++ ", " ++ show b ++ ")"

Why this translation:

  • Roc's structural tag unions become nominal ADTs in Haskell
  • Both enforce exhaustive pattern matching
  • Haskell allows pattern matching in function definitions, not just case expressions

Pattern 3: Result Type and Error Handling

Roc:

divide : I64, I64 -> Result I64 [DivByZero]
divide = \a, b ->
    if b == 0 then
        Err(DivByZero)
    else
        Ok(a // b)

# Using try (!) for propagation
calculate : I64, I64, I64 -> Result I64 [DivByZero]
calculate = \a, b, c ->
    x = divide!(a, b)
    y = divide!(x, c)
    Ok(y)

Haskell:

-- Either for errors (note reversed params from Roc)
data DivError = DivByZero deriving (Show, Eq)

divide :: Int64 -> Int64 -> Either DivError Int64
divide a 0 = Left DivByZero
divide a b = Right (a `div` b)

-- Using do-notation for propagation
calculate :: Int64 -> Int64 -> Int64 -> Either DivError Int64
calculate a b c = do
    x <- divide a b
    y <- divide x c
    return y

-- Or with applicative style
calculate' :: Int64 -> Int64 -> Int64 -> Either DivError Int64
calculate' a b c =
    divide a b >>= \x ->
    divide x c

Why this translation:

  • Roc's Result a e maps to Haskell's Either e a (type params reversed!)
  • Roc's ! suffix maps to Haskell's <- in do-notation
  • Both provide monadic error propagation
  • Haskell's Either is more general (any error type), Roc uses tag unions

Pattern 4: Abilities to Type Classes

Roc:

# Using ability constraint
toString : a -> Str where a implements Inspect
toString = \value ->
    Inspect.toStr(value)

# Custom type automatically implements abilities
User : {
    name : Str,
    age : U32,
}

user = { name: "Alice", age: 30 }
expect Inspect.toStr(user) == "{ name: \"Alice\", age: 30 }"

Haskell:

-- Type class constraint
toString :: (Show a) => a -> String
toString value = show value

-- Custom type with deriving
data User = User
    { name :: Text
    , age :: Word32
    } deriving (Show, Eq)

user = User "Alice" 30
-- show user == "User {name = \"Alice\", age = 30}"

Why this translation:

  • Roc abilities are similar to Haskell type classes
  • Roc auto-derives abilities; Haskell requires explicit deriving clauses
  • Haskell has more established type classes (Functor, Monad, etc.)

Pattern 5: List Pipeline Operations

Roc:

numbers = [1, 2, 3, 4, 5]

result = numbers
    |> List.map(\n -> n * 2)
    |> List.keepIf(\n -> n > 5)
    |> List.walk(0, \acc, n -> acc + n)

Haskell:

import Data.Function ((&))

numbers = [1, 2, 3, 4, 5]

-- Using function composition (right to left)
result = foldr (+) 0 . filter (>5) . map (*2) $ numbers

-- Or using & operator (left to right, like Roc)
result' = numbers
    & map (*2)
    & filter (>5)
    & foldr (+) 0

Why this translation:

  • Roc's |> maps to Haskell's & operator
  • Function composition . is more idiomatic but reads backward
  • Haskell's foldr/foldl map to Roc's List.walk

Error Handling

Result → Either Translation

Type Mapping:

Roc:     Result a e
         [Ok a, Err e]

Haskell: Either e a
         Left e | Right a

Key Difference: Type parameters are reversed!

Roc:

parseAge : Str -> Result U32 [ParseError Str]
parseAge = \str ->
    when Str.toU32(str) is
        Ok(n) -> Ok(n)
        Err(_) -> Err(ParseError("Not a valid number"))

# Chain operations
validateUser : Str, Str -> Result User [ParseError Str, InvalidEmail]
validateUser = \ageStr, emailStr ->
    age = parseAge!(ageStr)
    email = validateEmail!(emailStr)
    Ok({ name: "User", age, email })

Haskell:

import Text.Read (readMaybe)
import Data.Text (Text)

data ValidationError
    = ParseError String
    | InvalidEmail
    deriving (Show, Eq)

parseAge :: Text -> Either ValidationError Word32
parseAge str =
    case readMaybe (unpack str) of
        Just n -> Right n
        Nothing -> Left (ParseError "Not a valid number")

-- Chain with do-notation
validateUser :: Text -> Text -> Either ValidationError User
validateUser ageStr emailStr = do
    age <- parseAge ageStr
    email <- validateEmail emailStr
    return $ User "User" age email

Multiple Error Types

Roc:

# Tag union for multiple errors
Error : [ParseError Str, DivByZero, NetworkError Str]

process : Str, Str -> Result I64 Error
process = \aStr, bStr ->
    a = Str.toI64!(aStr) |> Result.mapErr(\_ -> ParseError("Invalid a"))
    b = Str.toI64!(bStr) |> Result.mapErr(\_ -> ParseError("Invalid b"))
    divide!(a, b) |> Result.mapErr(\_ -> DivByZero)

Haskell:

data Error
    = ParseError String
    | DivByZero
    | NetworkError String
    deriving (Show, Eq)

process :: Text -> Text -> Either Error Int64
process aStr bStr = do
    a <- parseI64 aStr `mapLeft` const (ParseError "Invalid a")
    b <- parseI64 bStr `mapLeft` const (ParseError "Invalid b")
    divide a b `mapLeft` const DivByZero
  where
    mapLeft f (Left e) = Left (f e)
    mapLeft _ (Right x) = Right x

Platform Model Translation

Roc Platform/Application → Haskell IO

Roc Application:

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

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

main : Task {} []
main =
    content = File.readUtf8!("input.txt")
    processed = Str.toUpper(content)
    File.writeUtf8!("output.txt", processed)
    Stdout.line!("Done!")

Haskell:

import qualified Data.Text.IO as TIO
import qualified Data.Text as T
import System.IO

main :: IO ()
main = do
    content <- TIO.readFile "input.txt"
    let processed = T.toUpper content
    TIO.writeFile "output.txt" processed
    putStrLn "Done!"

Key Differences:

  • Roc separates platform (I/O) from application (pure code)
  • Haskell uses IO monad throughout
  • Roc's ! suffix maps to Haskell's <- in do-notation
  • Both use monadic composition for effects

Task Error Handling

Roc:

readConfig : Str -> Task Config [FileNotFound, ParseError Str]
readConfig = \path ->
    content = File.readUtf8!(path)  # May fail with FileNotFound
    when parseJson(content) is
        Ok(config) -> Task.ok(config)
        Err(e) -> Task.err(ParseError(e))

Haskell:

import Control.Monad.Except
import qualified Data.Text.IO as TIO

data ConfigError
    = FileNotFound
    | ParseError String
    deriving (Show, Eq)

readConfig :: FilePath -> ExceptT ConfigError IO Config
readConfig path = do
    contentE <- liftIO $ try $ TIO.readFile path
    content <- case contentE of
        Left (_ :: IOException) -> throwError FileNotFound
        Right c -> return c
    case parseJson content of
        Left e -> throwError (ParseError e)
        Right config -> return config

Why this translation:

  • Roc Tasks with error types map to ExceptT err IO a
  • Both provide error propagation and recovery
  • Haskell separates IO errors (exceptions) from domain errors (Either/ExceptT)

Concurrency Patterns

Roc Task Concurrency → Haskell Async

Roc:

# Platform-provided concurrency
import pf.Task exposing [Task]
import pf.Http

fetchBoth : Task (Str, Str) [HttpErr]
fetchBoth =
    # Platform may execute concurrently
    Task.parallel2(
        Http.get("http://api.example.com/1"),
        Http.get("http://api.example.com/2")
    )

Haskell:

import Control.Concurrent.Async
import Network.HTTP.Simple

fetchBoth :: IO (ByteString, ByteString)
fetchBoth = concurrently
    (getResponseBody <$> httpBS "http://api.example.com/1")
    (getResponseBody <$> httpBS "http://api.example.com/2")

-- Or with race (first wins)
fetchFirst :: IO ByteString
fetchFirst = race
    (getResponseBody <$> httpBS "http://api.example.com/1")
    (getResponseBody <$> httpBS "http://api.example.com/2")
    >>= either return return

Why this translation:

  • Roc delegates all concurrency to the platform
  • Haskell provides explicit concurrency primitives (async library)
  • Both provide structured concurrency with proper cleanup

No Direct Threading

Roc:

# Roc applications don't directly manage threads
# All concurrency is platform capability
# Task composition is the only abstraction

Haskell:

import Control.Concurrent

-- Haskell provides lightweight threads
main = do
    forkIO $ do
        threadDelay 1000000
        putStrLn "Hello from thread"
    putStrLn "Main thread continues"
    threadDelay 2000000

-- Use async for structured concurrency (recommended)
import Control.Concurrent.Async

main' = do
    a <- async $ do
        threadDelay 1000000
        return "result"
    result <- wait a
    print result

Laziness Translation

Roc (Strict) → Haskell (Lazy)

Roc:

# Roc is strict by default
numbers = [1, 2, 3, 4, 5]

# This evaluates immediately
doubled = List.map(numbers, \n -> n * 2)

# Infinite lists require Stream or explicit laziness
naturals = Stream.iterate(0, \n -> n + 1)

Haskell:

-- Haskell is lazy by default
numbers = [1, 2, 3, 4, 5]

-- This creates a thunk, evaluated on demand
doubled = map (*2) numbers

-- Infinite lists work naturally
naturals = iterate (+1) 0
take 10 naturals  -- [0,1,2,3,4,5,6,7,8,9]

-- Force strict evaluation when needed
import Control.DeepSeq

strictDoubled = force $ map (*2) numbers

Key Differences:

  • Roc evaluates eagerly; add explicit limits before processing
  • Haskell evaluates lazily; infinite structures work out of the box
  • When converting, be careful with space leaks in Haskell (use seq, $!, or strict data structures)

Common Pitfalls

1. Result Type Parameter Order

❌ Assuming Roc Result and Haskell Either have same param order
✓ Remember: Result a e → Either e a (reversed!)

Roc:

divide : I64, I64 -> Result I64 [DivByZero]
#                    Result ^ok  ^err

Haskell:

divide :: Int64 -> Int64 -> Either DivError Int64
--                          Either ^err     ^ok

2. Structural vs Nominal Types

❌ Using Haskell tuples for Roc records
✓ Define proper ADTs with record syntax

Roc:

user = { name: "Alice", age: 30 }  # Structural type

Haskell:

-- Wrong: (Text, Word32)  -- Positional, no field names
-- Right:
data User = User { name :: Text, age :: Word32 }

3. Platform Boundary Confusion

❌ Trying to replicate Roc's platform model in Haskell
✓ Use IO monad or mtl transformers for effects

4. Strict vs Lazy Semantics

❌ Assuming Roc's eager evaluation in Haskell
✓ Add explicit limits (take, drop) before consuming infinite lists
✓ Use strict variants when performance matters (foldl', force)

5. Ability Auto-Derivation

❌ Expecting Haskell to auto-derive like Roc
✓ Add explicit deriving clauses (Show, Eq, etc.)

Tooling

| Tool | Purpose | Notes | |------|---------|-------| | GHC | Haskell compiler | Primary compiler | | GHCi | REPL | Interactive development | | Stack | Build tool | Dependency management, reproducible builds | | Cabal | Build tool | Alternative to Stack | | HLint | Linter | Code suggestions | | Ormolu / Brittany | Formatter | Code formatting | | hspec / QuickCheck | Testing | Unit and property-based tests |

No direct Roc→Haskell transpiler exists; conversion is manual.


Examples

Example 1: Simple - Tag Union Pattern Matching

Before (Roc):

Status : [Pending, Approved, Rejected]

handleStatus : Status -> Str
handleStatus = \status ->
    when status is
        Pending -> "Waiting..."
        Approved -> "Done!"
        Rejected -> "Failed"

After (Haskell):

data Status = Pending | Approved | Rejected
    deriving (Show, Eq)

handleStatus :: Status -> String
handleStatus Pending = "Waiting..."
handleStatus Approved = "Done!"
handleStatus Rejected = "Failed"

Example 2: Medium - Result with Error Propagation

Before (Roc):

divide : I64, I64 -> Result I64 [DivByZero]
divide = \a, b ->
    if b == 0 then
        Err(DivByZero)
    else
        Ok(a // b)

calculate : I64, I64, I64 -> Result I64 [DivByZero]
calculate = \a, b, c ->
    x = divide!(a, b)
    y = divide!(x, c)
    Ok(y)

# Usage
when calculate(20, 4, 2) is
    Ok(result) -> Num.toStr(result)
    Err(DivByZero) -> "Error: division by zero"

After (Haskell):

data DivError = DivByZero
    deriving (Show, Eq)

divide :: Int64 -> Int64 -> Either DivError Int64
divide _ 0 = Left DivByZero
divide a b = Right (a `div` b)

calculate :: Int64 -> Int64 -> Int64 -> Either DivError Int64
calculate a b c = do
    x <- divide a b
    y <- divide x c
    return y

-- Usage
result = case calculate 20 4 2 of
    Right r -> show r
    Left DivByZero -> "Error: division by zero"

Example 3: Complex - Platform Application to IO

Before (Roc):

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

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

Config : { port : U16, host : Str }

parseConfig : Str -> Result Config [ParseError Str]
parseConfig = \content ->
    # Parse JSON content
    when Json.decode(content) is
        Ok(config) -> Ok(config)
        Err(e) -> Err(ParseError(e))

main : Task {} []
main =
    # Read config file
    content = File.readUtf8!("config.json")

    # Parse config
    config = when parseConfig(content) is
        Ok(c) -> Task.ok!(c)
        Err(ParseError(msg)) ->
            Stdout.line!("Config error: \(msg)")
            Task.err!(ConfigError)

    # Use config
    Stdout.line!("Starting server on \(config.host):\(Num.toStr(config.port))")

After (Haskell):

{-# LANGUAGE DeriveGeneric #-}

import qualified Data.Text.IO as TIO
import qualified Data.Text as T
import Data.Aeson (FromJSON, eitherDecode)
import GHC.Generics
import Control.Monad.Except
import qualified Data.ByteString.Lazy as BL

data Config = Config
    { port :: Word16
    , host :: Text
    } deriving (Generic, Show)

instance FromJSON Config

data AppError
    = ParseError String
    | ConfigError
    deriving (Show, Eq)

parseConfig :: BL.ByteString -> Either AppError Config
parseConfig content =
    case eitherDecode content of
        Right config -> Right config
        Left err -> Left (ParseError err)

main :: IO ()
main = do
    result <- runExceptT $ do
        -- Read config file
        content <- liftIO $ BL.readFile "config.json"

        -- Parse config
        config <- case parseConfig content of
            Right c -> return c
            Left (ParseError msg) -> do
                liftIO $ putStrLn $ "Config error: " ++ msg
                throwError ConfigError

        -- Use config
        liftIO $ putStrLn $
            "Starting server on " ++ T.unpack (host config)
            ++ ":" ++ show (port config)

    case result of
        Left err -> putStrLn $ "Error: " ++ show err
        Right _ -> return ()

See Also

For more examples and patterns, see:

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

Cross-cutting pattern skills:

  • patterns-concurrency-dev - Compare Roc Task model with Haskell async/STM
  • patterns-serialization-dev - JSON handling across languages
  • patterns-metaprogramming-dev - Template Haskell vs no metaprogramming in Roc