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.
git clone https://github.com/aRustyDev/agents
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"
content/skills/convert-fsharp-roc/SKILL.mdConvert 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
- Foundational conversion patterns (APTV workflow, testing strategies)meta-convert-dev
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 |
|---|---|---|
| | Immutable strings |
| | Default signed integer |
| | 64-bit floating point |
| | Boolean values |
| | Immutable lists |
| | Arrays become lists |
| | Immutable dictionaries |
| | Immutable sets |
| | Optional values |
| | Error handling |
| `{ | ... | }` anonymous record |
discriminated union | tag union | Sum types |
| | Async/effects via platform |
| | Empty record (not quite ) |
When Converting Code
- Analyze source thoroughly before writing target
- Map types first - create type equivalence table
- Preserve semantics over syntax similarity
- Adopt Roc idioms - understand platform/application split
- Handle edge cases - null handling, error paths, effects
- 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 |
workflows | 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 |
|---|---|---|
| | Both immutable UTF-8 |
| | F# int is 32-bit, Roc defaults to 64 |
, , | , , | Explicit sizes match |
, , | , , | Unsigned variants |
| | 8-bit unsigned |
| | 8-bit signed |
, | , | F# float is F64 |
| No direct equivalent | Use external library or F64 |
| | Direct mapping |
| Use | Roc has no char type |
| | Empty record, not |
Collection Types
| F# | Roc | Notes |
|---|---|---|
| | Both immutable, structural sharing |
| | Roc lists handle array use cases |
| | Lazy sequences become lists |
| | Immutable maps |
| | Immutable sets |
tuple | | Tuples map directly |
| | Mutable becomes immutable |
Composite Types
| F# | Roc | Notes |
|---|---|---|
record | record | Structural typing in both |
| `{ | ... | }` anonymous record |
DU | tag union | Direct correspondence |
single-case DU | or opaque type | For newtype, use opaque |
| | Built-in DU vs tag union |
| | Built-in DU vs tag union |
| tag union | No built-in Choice |
F# Specific Types → Roc
| F# Type | Roc Strategy | Notes |
|---|---|---|
| | Platform-provided |
(.NET Task) | | Platform-provided |
| Thunks | No built-in lazy |
| Not needed | No mutable references |
function | | 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
type; Roc uses tag unionsOption<'a>[Some a, None] - F# has
; Roc uses pattern matching withOption.mapwhen - 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 (
); Roc uses try operator (result { ... }
)! - F#
uses strings; RocError "msg"
uses typed tagsErr(DivByZero) - 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#
→ Rocfilter
(more descriptive name)keepIf - F#
→ Rocsum
(explicit fold)walk(0, Num.add) - 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#
→ Rocmatch
(same exhaustiveness checking)when - F# interpolation
→ Roc interpolation$"{x}"
(different syntax)\(x)
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
keyword; Roc useswith
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:
- Convert all F# exceptions to Roc
typesResult - Convert F#
to Roctry/with
pattern matchingwhen ... is - 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
for success/failureResult - 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#
→ RocAsync<'a>
(platform-provided)Task a err - F#
→ Roclet!
suffix (try operator)! - F#
→ RocAsync.Parallel
(platform decides parallelism)Task.sequence - F# needs
; RocAsync.RunSynchronously
is already a Taskmain
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:
- Identify all F# code that does I/O
- Restructure to separate pure logic from effects
- Move effects to platform Task boundaries
- 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
that the platform executesTask - F# can call I/O anywhere; Roc separates pure from effectful code
Common Pitfalls
-
Assuming F# mutability works in Roc
- F# allows
keyword andmutable
cellsref - Roc has no mutable variables
- Fix: Redesign with immutable data structures
- F# allows
-
Trying to use F# exceptions
- F# has
,raise
, exception typestry/with - Roc only has
typeResult - Fix: Convert all exceptions to
with typed errorsResult
- F# has
-
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
-
Using F# computation expressions freely
- F# has
,async { }
,result { }
, etc.seq { } - Roc only has pattern matching and
operator! - Fix: Use
andwhen ... is
for control flow!
- F# has
-
Assuming type providers exist
- F# type providers generate types at compile time
- Roc has no metaprogramming
- Fix: Use external code generation tools
-
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
-
Using F# units of measure
- F# has
attribute for type-safe calculations[<Measure>] - Roc has no built-in units
- Fix: Use opaque types with smart constructors for validation
- F# has
-
Expecting REPL-driven development
- F# has F# Interactive (FSI) for REPL workflows
- Roc supports
but it's more limitedroc repl - Fix: Use
for inline tests insteadexpect
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#
is explicit in Roc, implicit in F#exposes
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
statementsexpect - 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:
- Metaprogramming: F# type providers have no Roc equivalent; use external codegen
- Serialization: F# has rich JSON/XML libraries; Roc relies on platform Encode/Decode abilities
- 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 |
|---|---|---|
CLI | Build, run, test, format | Equivalent to CLI |
| Roc LSP | Editor support | VS Code, vim, etc. |
| Code formatting | Like for F# |
| Run inline expects | Like |
| 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#
→ RocAsync<'a>
(platform-provided)Task a err - F#
→ Roc Result type with pattern matchingtry/with - F# computation expression → Roc
try operator! - F# can mix pure/async; Roc separates Task boundaries
See Also
For more examples and patterns, see:
- Foundational patterns with cross-language examplesmeta-convert-dev
- Another functional language pair conversion (similar paradigm shifts)convert-elm-clojure
- F# development patternslang-fsharp-dev
- Roc development patternslang-roc-dev
Cross-cutting pattern skills:
- Async workflows, Task model across languagespatterns-concurrency-dev
- JSON, validation across languagespatterns-serialization-dev
- Type providers vs code generationpatterns-metaprogramming-dev