AgentSkillsCN

convert-roc-haskell

将 Roc 代码转换为符合 Haskell 风格的代码。当需要将 Roc 项目迁移到 Haskell、将 Roc 的编程模式转化为符合 Haskell 风格的代码,或重构 Roc 代码库时,可使用此技能。该技能在 meta-convert-dev 的基础上,新增了专属于 Roc 到 Haskell 的转换模式。

SKILL.md
--- frontmatter
name: convert-roc-haskell
description: 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.

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

RocHaskellNotes
StrString or TextUse Text for production
U8, U16, U32, U64Word8, Word16, Word32, Word64Unsigned integers
I8, I16, I32, I64Int8, Int16, Int32, Int64Signed integers
F32, F64Float, DoubleFloating point
BoolBoolDirect mapping
List a[a]Lists
Dict k vMap k vUse Data.Map
Set aSet aUse Data.Set
Result a eEither e aNote reversed type params
[Tag1, Tag2]data X = Tag1 | Tag2Sum types
{ field : Type }data X = X { field :: Type }Records
Task a errIO a or ExceptT err IO aEffects
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

RocHaskellNotes
StrStringList of Char (less efficient)
StrTextPreferred from Data.Text
U8Word8From Data.Word
U16Word16From Data.Word
U32Word32From Data.Word
U64Word64From Data.Word
U128IntegerNo direct u128, use arbitrary precision
I8Int8From Data.Int
I16Int16From Data.Int
I32Int32From Data.Int
I64Int64From Data.Int
I128IntegerArbitrary precision
F32Float32-bit float
F64Double64-bit float
BoolBoolDirect mapping
Num aPolymorphic numberUse type classes

Collection Types

RocHaskellNotes
List a[a]Linked list
List aVector aFor indexed access (Data.Vector)
Dict k vMap k vFrom Data.Map
Set aSet aFrom Data.Set

Composite Types

RocHaskellNotes
{ name : Str, age : U32 }data User = User { name :: Text, age :: Word32 }Record syntax
[Ok a, Err e]Either e aNote: type params reversed!
[Some a, None]Maybe aOptional values
[Tag1, Tag2, Tag3]data X = Tag1 | Tag2 | Tag3Sum types
[Tag(T)]data X = Tag TTag with payload

Function Types

RocHaskellNotes
a -> ba -> bSimple function
a, b -> ca -> b -> cCurried by default
a -> b where a implements Eq(Eq a) => a -> bType class constraint

Idiom Translation

Pattern 1: Records and Record Updates

Roc:

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

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

# Field access
userName = user.name

Haskell:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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

Haskell: Either e a
         Left e | Right a

Key Difference: Type parameters are reversed!

Roc:

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:

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:

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:

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:

roc
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:

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:

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:

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:

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:

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
# Roc applications don't directly manage threads
# All concurrency is platform capability
# Task composition is the only abstraction

Haskell:

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
# 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
-- 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

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

Roc:

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

Haskell:

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

2. Structural vs Nominal Types

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

Roc:

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

Haskell:

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

3. Platform Boundary Confusion

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

4. Strict vs Lazy Semantics

code
❌ 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

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

Tooling

ToolPurposeNotes
GHCHaskell compilerPrimary compiler
GHCiREPLInteractive development
StackBuild toolDependency management, reproducible builds
CabalBuild toolAlternative to Stack
HLintLinterCode suggestions
Ormolu / BrittanyFormatterCode formatting
hspec / QuickCheckTestingUnit and property-based tests

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


Examples

Example 1: Simple - Tag Union Pattern Matching

Before (Roc):

roc
Status : [Pending, Approved, Rejected]

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

After (Haskell):

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):

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):

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):

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):

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