Claude Code Plugins

Community-maintained marketplace

Feedback

convert-fsharp-roc

@aRustyDev/ai
0
0

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

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

name convert-fsharp-roc
description Convert F# code to idiomatic Roc. Use when migrating F# projects to Roc, translating F# patterns to idiomatic Roc, or refactoring F# codebases. Extends meta-convert-dev with F#-to-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
  • Reverse conversion (Roc → F#) - see convert-roc-fsharp

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