Agents convert-fsharp-roc

Bidirectional conversion between Fsharp and Roc. Use when migrating projects between these languages in either direction. Extends meta-convert-dev with Fsharp↔Roc specific patterns.

install
source · Clone the upstream repo
git clone https://github.com/aRustyDev/agents
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/aRustyDev/agents "$T" && mkdir -p ~/.claude/skills && cp -r "$T/content/skills/convert-fsharp-roc" ~/.claude/skills/arustydev-agents-convert-fsharp-roc && rm -rf "$T"
manifest: content/skills/convert-fsharp-roc/SKILL.md
source content

Convert F# to Roc

Convert F# code to idiomatic Roc. This skill extends

meta-convert-dev
with F#-to-Roc specific type mappings, idiom translations, and architectural guidance.

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: F# types → Roc types
  • Idiom translations: F# patterns → idiomatic Roc
  • Error handling: F# Result/Option → Roc Result/tag unions
  • Platform shift: .NET runtime → Roc platform model
  • Paradigm alignment: Both functional-first, but different architectures

This Skill Does NOT Cover

  • General conversion methodology - see
    meta-convert-dev
  • F# language fundamentals - see
    lang-fsharp-dev
  • Roc language fundamentals - see
    lang-roc-dev

Quick Reference

F#RocNotes
string
Str
Immutable strings
int
I64
Default signed integer
float
F64
64-bit floating point
bool
Bool
Boolean values
'a list
List a
Immutable lists
'a array
List a
Arrays become lists
Map<'k,'v>
Dict k v
Immutable dictionaries
Set<'a>
Set a
Immutable sets
Option<'a>
[Some a, None]
Optional values
Result<'a,'e>
Result a e
Error handling
`{...}` anonymous record
type X = ...
discriminated union
[...]
tag union
Sum types
Async<'a>
Task a err
Async/effects via platform
unit
{}
Empty record (not quite
()
)

When Converting Code

  1. Analyze source thoroughly before writing target
  2. Map types first - create type equivalence table
  3. Preserve semantics over syntax similarity
  4. Adopt Roc idioms - understand platform/application split
  5. Handle edge cases - null handling, error paths, effects
  6. Test equivalence - same inputs → same outputs

Paradigm Translation

Mental Model Shift: .NET Runtime → Platform Model

Both F# and Roc are functional-first languages, but they differ fundamentally in how they handle effects:

F# ConceptRoc ApproachKey Insight
.NET runtime with GCPlatform provides runtimeRuntime is external to application
Async<'a>
workflows
Task ok err
via platform
Effects delegated to platform
Direct I/O (Console, File)Platform-provided I/OApplication remains pure
Mutable state allowedImmutable by defaultNo mutable keyword
Exception handlingResult type onlyNo runtime exceptions
Type providersCode generation externalNo compile-time metaprogramming

Architecture Mental Model

F# (.NET)                          Roc (Platform Model)
┌─────────────────────┐           ┌─────────────────────┐
│   Your F# Code      │           │   Your Roc Code     │
│  (can do I/O)       │           │   (pure only)       │
│  ↓                  │           │   ↓                 │
│  .NET BCL           │           │   Platform API      │
│  ↓                  │           │   ↓                 │
│  CLR Runtime        │           │   Platform Host     │
└─────────────────────┘           └─────────────────────┘
     Everything in                    Clear separation
     same runtime                     between pure & effects

Key shift: In F#, you can call

Console.WriteLine
anywhere. In Roc, all I/O goes through the platform's
Task
type.


Type System Mapping

Primitive Types

F#RocNotes
string
Str
Both immutable UTF-8
int
I64
F# int is 32-bit, Roc defaults to 64
int16
,
int32
,
int64
I16
,
I32
,
I64
Explicit sizes match
uint16
,
uint32
,
uint64
U16
,
U32
,
U64
Unsigned variants
byte
U8
8-bit unsigned
sbyte
I8
8-bit signed
float
,
double
F32
,
F64
F# float is F64
decimal
No direct equivalentUse external library or F64
bool
Bool
Direct mapping
char
Use
Str
Roc has no char type
unit
{}
Empty record, not
()

Collection Types

F#RocNotes
'a list
List a
Both immutable, structural sharing
'a array
List a
Roc lists handle array use cases
'a seq
List a
Lazy sequences become lists
Map<'k,'v>
Dict k v
Immutable maps
Set<'a>
Set a
Immutable sets
('a * 'b)
tuple
(a, b)
Tuples map directly
ResizeArray<'a>
List a
Mutable becomes immutable

Composite Types

F#RocNotes
type X = { ... }
record
{ ... }
record
Structural typing in both
`{...}` anonymous record
type X = A | B | C
DU
[A, B, C]
tag union
Direct correspondence
type X = A of int
single-case DU
[A I64]
or opaque type
For newtype, use opaque
Option<'a>
[Some a, None]
Built-in DU vs tag union
Result<'ok,'err>
Result ok err
Built-in DU vs tag union
Choice<'a,'b>
[A a, B b]
tag union
No built-in Choice

F# Specific Types → Roc

F# TypeRoc StrategyNotes
Async<'a>
Task a err
Platform-provided
Task<'a>
(.NET Task)
Task a err
Platform-provided
Lazy<'a>
Thunks
({} -> a)
No built-in lazy
ref<'a>
Not neededNo mutable references
'a -> 'b
function
a -> b
Functions map directly
Type providersExternal codegenNo compile-time metaprogramming
Units of measureCustom validationNo built-in units

Idiom Translation

Pattern 1: Option Handling

F#:

let findUser id =
    users |> List.tryFind (fun u -> u.Id = id)

let userName =
    findUser 1
    |> Option.map (fun u -> u.Name)
    |> Option.defaultValue "Unknown"

Roc:

findUser : I64 -> [Some User, None]
findUser = \id ->
    List.findFirst(users, \u -> u.id == id)
    |> Result.toOption  # Convert Result to Option-like tag

userName =
    when findUser(1) is
        Some(u) -> u.name
        None -> "Unknown"

Why this translation:

  • F# has built-in
    Option<'a>
    type; Roc uses tag unions
    [Some a, None]
  • F# has
    Option.map
    ; Roc uses pattern matching with
    when
  • Both are structural sum types under the hood

Pattern 2: Result for Error Handling

F#:

let divide x y =
    if y = 0 then
        Error "Division by zero"
    else
        Ok (x / y)

let calculate a b c =
    result {
        let! x = divide a b
        let! y = divide x c
        return y
    }

Roc:

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

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

Why this translation:

  • F# has computation expressions (
    result { ... }
    ); Roc uses try operator (
    !
    )
  • F#
    Error "msg"
    uses strings; Roc
    Err(DivByZero)
    uses typed tags
  • Both propagate errors up the call stack

Pattern 3: List Operations

F#:

let result =
    items
    |> List.filter (fun x -> x.Active)
    |> List.map (fun x -> x.Value)
    |> List.sum

Roc:

result =
    items
    |> List.keepIf(\x -> x.active)  # filter → keepIf
    |> List.map(\x -> x.value)
    |> List.walk(0, Num.add)  # sum via walk (fold)

Why this translation:

  • F#
    filter
    → Roc
    keepIf
    (more descriptive name)
  • F#
    sum
    → Roc
    walk(0, Num.add)
    (explicit fold)
  • Both use pipeline operator (
    |>
    ) idiomatically

Pattern 4: Pattern Matching

F#:

type Color =
    | Red
    | Green
    | Blue
    | Custom of r: int * g: int * b: int

let describe color =
    match color with
    | Red -> "red"
    | Green -> "green"
    | Blue -> "blue"
    | Custom (r, g, b) -> $"rgb({r}, {g}, {b})"

Roc:

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

describe : Color -> Str
describe = \color ->
    when color is
        Red -> "red"
        Green -> "green"
        Blue -> "blue"
        Custom(r, g, b) -> "rgb(\(Num.toStr(r)), \(Num.toStr(g)), \(Num.toStr(b)))"

Why this translation:

  • F# discriminated unions → Roc tag unions (nearly identical)
  • F#
    match
    → Roc
    when
    (same exhaustiveness checking)
  • F# interpolation
    $"{x}"
    → Roc interpolation
    \(x)
    (different syntax)

Pattern 5: Record Updates

F#:

type Person = {
    FirstName: string
    LastName: string
    Age: int
}

let person = { FirstName = "Alice"; LastName = "Smith"; Age = 30 }
let olderPerson = { person with Age = 31 }

Roc:

Person : {
    firstName : Str,
    lastName : Str,
    age : U32,
}

person = { firstName: "Alice", lastName: "Smith", age: 30 }
olderPerson = { person & age: 31 }

Why this translation:

  • F# uses
    with
    keyword; Roc uses
    &
    operator
  • Both create new records (copy-on-write)
  • F# uses PascalCase by convention; Roc uses camelCase

Pattern 6: Pipeline Composition

F#:

let processUser =
    fetchUser
    >> validateUser
    >> saveUser

// Or with pipe
let result =
    userId
    |> fetchUser
    |> validateUser
    |> saveUser

Roc:

# Roc doesn't have >> composition operator
# Use pipeline instead
result =
    userId
    |> fetchUser
    |> validateUser
    |> saveUser

Why this translation:

  • F# has both
    >>
    (forward composition) and
    |>
    (pipeline)
  • Roc only has
    |>
    (pipeline) - prefer this style
  • Same left-to-right data flow

Error Handling

F# Exception Model → Roc Result Model

F# supports both exceptions and

Result<'a,'e>
. Roc only has
Result
.

F#:

// Style 1: Exceptions
let divide x y =
    if y = 0 then
        raise (DivideByZeroException())
    else
        x / y

try
    let result = divide 10 0
    printfn $"Result: {result}"
with
| :? DivideByZeroException -> printfn "Cannot divide by zero"

// Style 2: Result (preferred for F# interop)
let safeDivide x y =
    if y = 0 then
        Error "Division by zero"
    else
        Ok (x / y)

Roc:

# Only Result style - no exceptions
divide : I64, I64 -> Result I64 [DivByZero]
divide = \x, y ->
    if y == 0 then
        Err(DivByZero)
    else
        Ok(x // y)

# Handling
when divide(10, 0) is
    Ok(result) -> Stdout.line!("Result: \(Num.toStr(result))")
    Err(DivByZero) -> Stdout.line!("Cannot divide by zero")

Migration strategy:

  1. Convert all F# exceptions to Roc
    Result
    types
  2. Convert F#
    try/with
    to Roc
    when ... is
    pattern matching
  3. Use
    !
    (try operator) for error propagation instead of exception bubbling

Multiple Error Types

F#:

type ValidationError =
    | EmptyName
    | InvalidAge
    | InvalidEmail

let validatePerson name age email =
    if String.IsNullOrWhiteSpace(name) then
        Error EmptyName
    elif age < 0 || age > 120 then
        Error InvalidAge
    elif not (email.Contains("@")) then
        Error InvalidEmail
    else
        Ok { Name = name; Age = age; Email = email }

Roc:

ValidationError : [EmptyName, InvalidAge, InvalidEmail]

validatePerson : Str, I64, Str -> Result Person ValidationError
validatePerson = \name, age, email ->
    if Str.isEmpty(name) then
        Err(EmptyName)
    else if age < 0 || age > 120 then
        Err(InvalidAge)
    else if !(Str.contains(email, "@")) then
        Err(InvalidEmail)
    else
        Ok({ name, age, email })

Why this translation:

  • Both use discriminated unions/tag unions for error types
  • Both use
    Result
    for success/failure
  • Both have exhaustive pattern matching

Async and Effects

F# Async → Roc Task

This is a significant paradigm shift. F#

Async
runs on the .NET runtime; Roc
Task
is platform-provided.

F#:

let fetchData url = async {
    let! response = httpClient.GetStringAsync(url) |> Async.AwaitTask
    return response
}

let processMultiple urls = async {
    let! results =
        urls
        |> List.map fetchData
        |> Async.Parallel
    return Array.toList results
}

// Run the async
let result = processMultiple urls |> Async.RunSynchronously

Roc:

# Platform provides Task and Http
import pf.Http
import pf.Task exposing [Task]

fetchData : Str -> Task Str [HttpErr]
fetchData = \url ->
    Http.get!(url)  # Platform handles async

processMultiple : List Str -> Task (List Str) [HttpErr]
processMultiple = \urls ->
    # Platform may parallelize this
    urls
    |> List.map(fetchData)
    |> Task.sequence  # Platform-provided

# main is already a Task - no explicit run
main : Task {} []
main =
    results = processMultiple!(urls)
    Stdout.line!("Done")

Why this translation:

  • F#
    Async<'a>
    → Roc
    Task a err
    (platform-provided)
  • F#
    let!
    → Roc
    !
    suffix (try operator)
  • F#
    Async.Parallel
    → Roc
    Task.sequence
    (platform decides parallelism)
  • F# needs
    Async.RunSynchronously
    ; Roc
    main
    is already a Task

Pure vs Effectful Code

F#:

// Pure computation
let add x y = x + y

// Effectful computation (can do I/O anywhere)
let greet name =
    printfn $"Hello, {name}!"
    name

Roc:

# Pure computation
add : I64, I64 -> I64
add = \x, y -> x + y

# Effectful computation (must return Task)
greet : Str -> Task Str []
greet = \name ->
    Stdout.line!("Hello, \(name)!")
    Task.ok(name)  # Return pure value in Task

Migration strategy:

  1. Identify all F# code that does I/O
  2. Restructure to separate pure logic from effects
  3. Move effects to platform Task boundaries
  4. Keep business logic pure

Platform Architecture

.NET Application → Roc Application + Platform

F# (.NET Console App):

[<EntryPoint>]
let main argv =
    let input = Console.ReadLine()
    let processed = processInput input
    Console.WriteLine(processed)
    0  // Return exit code

Roc (Platform-based):

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

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

main : Task {} []
main =
    input = Stdin.line!
    processed = processInput(input)  # Pure function
    Stdout.line!(processed)

# Pure helper (no effects)
processInput : Str -> Str
processInput = \input ->
    Str.toUpper(input)

Key differences:

  • F# entry point is a function that returns int (exit code)
  • Roc entry point is a
    Task
    that the platform executes
  • F# can call I/O anywhere; Roc separates pure from effectful code

Common Pitfalls

  1. Assuming F# mutability works in Roc

    • F# allows
      mutable
      keyword and
      ref
      cells
    • Roc has no mutable variables
    • Fix: Redesign with immutable data structures
  2. Trying to use F# exceptions

    • F# has
      raise
      ,
      try/with
      , exception types
    • Roc only has
      Result
      type
    • Fix: Convert all exceptions to
      Result
      with typed errors
  3. Expecting .NET BCL libraries

    • F# has access to entire .NET Base Class Library
    • Roc only has what the platform provides
    • Fix: Check platform docs for available APIs
  4. Using F# computation expressions freely

    • F# has
      async { }
      ,
      result { }
      ,
      seq { }
      , etc.
    • Roc only has pattern matching and
      !
      operator
    • Fix: Use
      when ... is
      and
      !
      for control flow
  5. Assuming type providers exist

    • F# type providers generate types at compile time
    • Roc has no metaprogramming
    • Fix: Use external code generation tools
  6. Forgetting platform/application split

    • F# code is all in the same runtime
    • Roc strictly separates pure (app) from effects (platform)
    • Fix: Keep business logic pure, push effects to boundaries
  7. Using F# units of measure

    • F# has
      [<Measure>]
      attribute for type-safe calculations
    • Roc has no built-in units
    • Fix: Use opaque types with smart constructors for validation
  8. Expecting REPL-driven development

    • F# has F# Interactive (FSI) for REPL workflows
    • Roc supports
      roc repl
      but it's more limited
    • Fix: Use
      expect
      for inline tests instead

Module System

F# Modules/Namespaces → Roc Interfaces

F#:

// UserModule.fs
namespace MyApp

module User =
    type User = {
        Id: int
        Name: string
        Email: string
    }

    let create name email = {
        Id = generateId()
        Name = name
        Email = email
    }

    let getName user = user.Name

Roc:

# User.roc
interface User
    exposes [User, create, getName]
    imports []

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

create : Str, Str -> User
create = \name, email -> {
    id: generateId(),
    name,
    email,
}

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

Migration notes:

  • F# namespaces → Not needed in Roc (file-based modules)
  • F# modules → Roc interfaces
  • F#
    exposes
    is explicit in Roc, implicit in F#

Build System

.NET Project → Roc Application

F# (.fsproj):

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <Compile Include="Types.fs" />
    <Compile Include="Logic.fs" />
    <Compile Include="Program.fs" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="FSharp.Data" Version="6.3.0" />
  </ItemGroup>
</Project>

Roc:

# main.roc - single file or multiple interfaces
app [main] {
    pf: platform "https://..."
}

import Types
import Logic

main : Task {} []
main =
    Logic.run

Build commands:

# F#
dotnet build
dotnet run

# Roc
roc build main.roc
roc run main.roc

Key differences:

  • F# needs .fsproj and explicit file ordering
  • Roc infers dependencies from imports
  • F# uses NuGet for packages; Roc uses platform URLs

Testing

F# Testing → Roc Expect

F# (Expecto):

module Tests

open Expecto

[<Tests>]
let tests =
    testList "Math tests" [
        testCase "addition" <| fun () ->
            Expect.equal (2 + 2) 4 "2 + 2 = 4"

        testCase "division by zero" <| fun () ->
            let result = divide 10 0
            Expect.equal result (Error "Division by zero") "should error"
    ]

[<EntryPoint>]
let main args =
    runTestsWithCLIArgs [] args tests

Roc:

# Inline tests with expect
add : I64, I64 -> I64
add = \x, y -> x + y

expect add(2, 2) == 4

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

expect divide(10, 0) == Err(DivByZero)
expect divide(10, 2) == Ok(5)

Run tests:

# F#
dotnet test

# Roc
roc test main.roc

Migration strategy:

  • Convert Expecto/xUnit/NUnit tests to Roc
    expect
    statements
  • Place expects near the functions they test
  • Run with
    roc test

Limitations

Coverage Gaps

PillarF# SkillRoc SkillMitigation
ModuleBoth well-documented
Error✓ (Result + exceptions)✓ (Result only)See Error Handling section
Concurrency~ (Async covered)See Async and Effects section
Metaprogramming~ (Type providers)✓ (minimalist)External code generation
Zero/Default✓ (implicit)~ (via pattern matching)Use tag unions for nullable
Serialization~ (via abilities)See patterns-serialization-dev
Build~ (emerging)Roc build system is simpler
TestingBoth covered adequately

Combined Score: 14/16 (Good)

Known Limitations:

  1. Metaprogramming: F# type providers have no Roc equivalent; use external codegen
  2. Serialization: F# has rich JSON/XML libraries; Roc relies on platform Encode/Decode abilities
  3. Concurrency: F# Async is mature; Roc Task model is platform-dependent

External Resources Used

ResourceWhat It ProvidedReliability
F# for Fun and ProfitIdiom examplesHigh
Roc TutorialPlatform model guidanceHigh
lang-fsharp-devType system detailsHigh
lang-roc-devTask and platform patternsHigh

Tooling

ToolPurposeNotes
roc
CLI
Build, run, test, formatEquivalent to
dotnet
CLI
Roc LSPEditor supportVS Code, vim, etc.
roc format
Code formattingLike
fantomas
for F#
roc test
Run inline expectsLike
dotnet test
External codegenType generationReplaces F# type providers

Examples

Example 1: Simple - Option Handling

Before (F#):

type User = { Id: int; Name: string; Email: string }

let users = [
    { Id = 1; Name = "Alice"; Email = "alice@example.com" }
    { Id = 2; Name = "Bob"; Email = "bob@example.com" }
]

let findUserById id =
    users |> List.tryFind (fun u -> u.Id = id)

let getUserName id =
    findUserById id
    |> Option.map (fun u -> u.Name)
    |> Option.defaultValue "Unknown"

After (Roc):

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

users = [
    { id: 1, name: "Alice", email: "alice@example.com" },
    { id: 2, name: "Bob", email: "bob@example.com" },
]

findUserById : I64 -> [Some User, None]
findUserById = \id ->
    when List.findFirst(users, \u -> u.id == id) is
        Ok(user) -> Some(user)
        Err(_) -> None

getUserName : I64 -> Str
getUserName = \id ->
    when findUserById(id) is
        Some(u) -> u.name
        None -> "Unknown"

Example 2: Medium - Result Error Handling

Before (F#):

type ValidationError =
    | InvalidName
    | InvalidAge

type Person = { Name: string; Age: int }

let validateName name =
    if String.IsNullOrWhiteSpace(name) then
        Error InvalidName
    else
        Ok name

let validateAge age =
    if age < 0 || age > 120 then
        Error InvalidAge
    else
        Ok age

let createPerson name age =
    result {
        let! validName = validateName name
        let! validAge = validateAge age
        return { Name = validName; Age = validAge }
    }

After (Roc):

ValidationError : [InvalidName, InvalidAge]

Person : { name : Str, age : I64 }

validateName : Str -> Result Str [InvalidName]
validateName = \name ->
    if Str.isEmpty(name) then
        Err(InvalidName)
    else
        Ok(name)

validateAge : I64 -> Result I64 [InvalidAge]
validateAge = \age ->
    if age < 0 || age > 120 then
        Err(InvalidAge)
    else
        Ok(age)

createPerson : Str, I64 -> Result Person [InvalidName, InvalidAge]
createPerson = \name, age ->
    validName = validateName!(name)
    validAge = validateAge!(age)
    Ok({ name: validName, age: validAge })

Example 3: Complex - Async File Processing

Before (F#):

open System.IO

type ProcessingError =
    | FileNotFound of string
    | InvalidFormat of string

let readFile path = async {
    try
        let! content = File.ReadAllTextAsync(path) |> Async.AwaitTask
        return Ok content
    with
    | :? FileNotFoundException ->
        return Error (FileNotFound path)
}

let processContent content =
    if content.Contains("error") then
        Error (InvalidFormat "Content contains error")
    else
        Ok (content.ToUpper())

let writeFile path content = async {
    do! File.WriteAllTextAsync(path, content) |> Async.AwaitTask
    return Ok ()
}

let processFile inputPath outputPath = async {
    let! contentResult = readFile inputPath
    match contentResult with
    | Error e -> return Error e
    | Ok content ->
        match processContent content with
        | Error e -> return Error e
        | Ok processed ->
            return! writeFile outputPath processed
}

// Usage
let result =
    processFile "input.txt" "output.txt"
    |> Async.RunSynchronously

After (Roc):

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

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

ProcessingError : [FileNotFound Str, InvalidFormat Str]

readFile : Str -> Task Str [FileReadErr Path.ReadErr]*
readFile = \path ->
    File.readUtf8(Path.fromStr(path))

processContent : Str -> Result Str [InvalidFormat Str]
processContent = \content ->
    if Str.contains(content, "error") then
        Err(InvalidFormat("Content contains error"))
    else
        Ok(Str.toUpper(content))

writeFile : Str, Str -> Task {} [FileWriteErr Path.WriteErr]*
writeFile = \path, content ->
    File.writeUtf8(Path.fromStr(path), content)

processFile : Str, Str -> Task {} [FileReadErr Path.ReadErr, InvalidFormat Str, FileWriteErr Path.WriteErr]*
processFile = \inputPath, outputPath ->
    # Read file (returns Task)
    content = readFile!(inputPath)

    # Process content (pure function, returns Result)
    processed = processContent!(content)

    # Write file (returns Task)
    writeFile!(outputPath, processed)

main : Task {} []
main =
    when processFile("input.txt", "output.txt") is
        Ok({}) -> Stdout.line!("File processed successfully")
        Err(FileReadErr(_)) -> Stdout.line!("Error reading file")
        Err(InvalidFormat(msg)) -> Stdout.line!("Invalid format: \(msg)")
        Err(FileWriteErr(_)) -> Stdout.line!("Error writing file")

Key conversions:

  • F#
    Async<'a>
    → Roc
    Task a err
    (platform-provided)
  • F#
    try/with
    → Roc Result type with pattern matching
  • F# computation expression → Roc
    !
    try operator
  • F# can mix pure/async; Roc separates Task boundaries

See Also

For more examples and patterns, see:

  • meta-convert-dev
    - Foundational patterns with cross-language examples
  • convert-elm-clojure
    - Another functional language pair conversion (similar paradigm shifts)
  • lang-fsharp-dev
    - F# development patterns
  • lang-roc-dev
    - Roc development patterns

Cross-cutting pattern skills:

  • patterns-concurrency-dev
    - Async workflows, Task model across languages
  • patterns-serialization-dev
    - JSON, validation across languages
  • patterns-metaprogramming-dev
    - Type providers vs code generation