Agent Skills: Convert F# to 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.

UncategorizedID: arustydev/ai/convert-fsharp-roc

Repository

aRustyDevLicense: AGPL-3.0
72

Install this agent skill to your local

pnpm dlx add-skill https://github.com/aRustyDev/agents/tree/HEAD/content/skills/convert-fsharp-roc

Skill Files

Browse the full folder contents for convert-fsharp-roc.

Download Skill

Loading file tree…

content/skills/convert-fsharp-roc/SKILL.md

Skill Metadata

Name
convert-fsharp-roc
Description
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.

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# | Roc | Notes | |----------|----------|-------| | 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 | { ... } record | Structural records | | 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# Concept | Roc Approach | Key Insight | |------------------|-------------------|-------------| | .NET runtime with GC | Platform provides runtime | Runtime is external to application | | Async<'a> workflows | Task ok err via platform | Effects delegated to platform | | Direct I/O (Console, File) | Platform-provided I/O | Application remains pure | | Mutable state allowed | Immutable by default | No mutable keyword | | Exception handling | Result type only | No runtime exceptions | | Type providers | Code generation external | No 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# | Roc | Notes | |----------|----------|-------| | 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 equivalent | Use external library or F64 | | bool | Bool | Direct mapping | | char | Use Str | Roc has no char type | | unit | {} | Empty record, not () |

Collection Types

| F# | Roc | Notes | |----------|----------|-------| | '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# | Roc | Notes | |----------|----------|-------| | type X = { ... } record | { ... } record | Structural typing in both | | {| ... |} anonymous record | { ... } record | All Roc records are structural | | 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# Type | Roc Strategy | Notes | |----------|----------|-------| | 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 needed | No mutable references | | 'a -> 'b function | a -> b | Functions map directly | | Type providers | External codegen | No compile-time metaprogramming | | Units of measure | Custom validation | No 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

| Pillar | F# Skill | Roc Skill | Mitigation | |--------|--------------|--------------|------------| | Module | ✓ | ✓ | Both 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 | | Testing | ✓ | ✓ | Both 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

| Resource | What It Provided | Reliability | |----------|------------------|-------------| | F# for Fun and Profit | Idiom examples | High | | Roc Tutorial | Platform model guidance | High | | lang-fsharp-dev | Type system details | High | | lang-roc-dev | Task and platform patterns | High |


Tooling

| Tool | Purpose | Notes | |------|---------|-------| | roc CLI | Build, run, test, format | Equivalent to dotnet CLI | | Roc LSP | Editor support | VS Code, vim, etc. | | roc format | Code formatting | Like fantomas for F# | | roc test | Run inline expects | Like dotnet test | | External codegen | Type generation | Replaces 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