Claude-skill-registry convert-roc-fsharp
Convert Roc code to idiomatic F#. Use when migrating Roc projects to F#, translating Roc patterns to idiomatic F#, or refactoring Roc codebases. Extends meta-convert-dev with Roc-to-F# specific patterns.
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/convert-roc-fsharp" ~/.claude/skills/majiayu000-claude-skill-registry-convert-roc-fsharp && rm -rf "$T"
skills/data/convert-roc-fsharp/SKILL.mdConvert Roc to F#
Convert Roc code to idiomatic F#. This skill extends
meta-convert-dev with Roc-to-F# 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: Roc types → F# types
- Idiom translations: Roc patterns → idiomatic F#
- Error handling: Roc Result/tag unions → F# Result/Option
- Platform shift: Roc platform model → .NET runtime
- Paradigm alignment: Both functional-first, but different architectures
This Skill Does NOT Cover
- General conversion methodology - see
meta-convert-dev - Roc language fundamentals - see
lang-roc-dev - F# language fundamentals - see
lang-fsharp-dev - Reverse conversion (F# → Roc) - see
convert-fsharp-roc
Quick Reference
| Roc | F# | Notes |
|---|---|---|
| | Immutable strings |
| | 64-bit signed integer |
| | F# default int is 32-bit |
| or | 64-bit floating point |
| | Boolean values |
| | Immutable lists |
| | Immutable dictionaries |
| | Immutable sets |
| | Optional values |
| | Error handling |
record | `{ | ... |
tag union | discriminated union | Sum types |
| or | Async/effects |
| | Empty value |
When Converting Code
- Analyze source thoroughly before writing target
- Map types first - create type equivalence table
- Preserve semantics over syntax similarity
- Adopt F# idioms - leverage .NET ecosystem
- Handle edge cases - error paths, effects, resource management
- Test equivalence - same inputs → same outputs
Paradigm Translation
Mental Model Shift: Platform Model → .NET Runtime
Both Roc and F# are functional-first languages, but they differ fundamentally in how they handle effects:
| Roc Concept | F# Approach | Key Insight |
|---|---|---|
| Platform provides runtime | .NET CLR runtime | Runtime is part of the application |
via platform | workflows | Effects integrated into language |
| Platform-provided I/O | Direct I/O (Console, File) | Can do I/O anywhere |
| Application remains pure | Can mix pure and impure | Flexibility over purity |
| No runtime exceptions | Exception handling | Exceptions are first-class |
| No compile-time metaprogramming | Type providers | Rich compile-time features |
Architecture Mental Model
Roc (Platform Model) F# (.NET) ┌─────────────────────┐ ┌─────────────────────┐ │ Your Roc Code │ │ Your F# Code │ │ (pure only) │ │ (can do I/O) │ │ ↓ │ │ ↓ │ │ Platform API │ │ .NET BCL │ │ ↓ │ │ ↓ │ │ Platform Host │ │ CLR Runtime │ └─────────────────────┘ └─────────────────────┘ Clear separation Everything in between pure & effects same runtime
Key shift: In Roc, I/O goes through the platform's
Task type. In F#, you can call Console.WriteLine or perform I/O anywhere.
Type System Mapping
Primitive Types
| Roc | F# | Notes |
|---|---|---|
| | Both immutable UTF-8 |
| | 8-bit signed |
| | 16-bit signed |
| | F# default int is 32-bit |
| or | 64-bit signed |
| | No native 128-bit int |
| | 8-bit unsigned |
| | 16-bit unsigned |
| | 32-bit unsigned |
| or | 64-bit unsigned |
| | No native 128-bit uint |
| or | 32-bit floating point |
| or | 64-bit floating point (F# default) |
| | Direct mapping |
| | Empty value |
Collection Types
| Roc | F# | Notes |
|---|---|---|
| | Both immutable, structural sharing |
| | Immutable dictionaries |
| | Immutable sets |
| | Tuples map directly |
| | Multiple element tuples |
Composite Types
| Roc | F# | Notes |
|---|---|---|
record | `{ | ... |
record | record | Nominal typing (preferred) |
tag union | DU | Direct correspondence |
tag with payload | | Payload mapping |
| | Built-in option type |
| | Built-in result type |
Roc Specific Types → F#
| Roc Type | F# Strategy | Notes |
|---|---|---|
| or | Platform effects → runtime async |
| Opaque types | Single-case DU | |
| Tag unions (open) | Extensible DU (rare) | Use closed DU instead |
| Abilities constraints | Interface constraints | |
Idiom Translation
Pattern 1: Tag Unions to Discriminated Unions
Roc:
Color : [Red, Green, Blue, Custom(U8, U8, U8)] 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)))"
F#:
type Color = | Red | Green | Blue | Custom of r: byte * g: byte * b: byte let describe color = match color with | Red -> "red" | Green -> "green" | Blue -> "blue" | Custom (r, g, b) -> $"rgb({r}, {g}, {b})"
Why this translation:
- Roc tag unions → F# discriminated unions (nearly identical)
- Roc
→ F#when
(same exhaustiveness checking)match - Roc interpolation
→ F# interpolation\(x)
or$"{x}"{x} - Both enforce exhaustive pattern matching
Pattern 2: Optional Values
Roc:
findUser : I64 -> [Some User, None] findUser = \id -> List.findFirst(users, \u -> u.id == id) |> Result.toOption userName = when findUser(1) is Some(u) -> u.name None -> "Unknown"
F#:
let findUser id = users |> List.tryFind (fun u -> u.Id = id) let userName = match findUser 1 with | Some u -> u.Name | None -> "Unknown"
Why this translation:
- Roc
→ F#[Some a, None]
(built-in type)Option<'a> - Roc pattern matching → F# pattern matching (direct mapping)
- F# has
,Option.map
helpers not shown in Roc exampleOption.bind - Both achieve same null-safety
Pattern 3: Result Error Handling
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) y = divide!(x, c) Ok(y)
F#:
type DivisionError = DivByZero let divide x y = if y = 0 then Error DivByZero else Ok (x / y) let calculate a b c = result { let! x = divide a b let! y = divide x c return y }
Why this translation:
- Roc
→ F#Result ok err
(built-in)Result<'ok,'err> - Roc
try operator → F#!
in computation expressionlet! - Roc tag errors
→ F# DU[DivByZero]type DivisionError = DivByZero - F# computation expressions provide cleaner syntax than nested matches
Pattern 4: List Operations
Roc:
result = items |> List.keepIf(\x -> x.active) |> List.map(\x -> x.value) |> List.walk(0, Num.add)
F#:
let result = items |> List.filter (fun x -> x.Active) |> List.map (fun x -> x.Value) |> List.sum
Why this translation:
- Roc
→ F#keepIf
(different naming)filter - Roc
→ F#walk(0, Num.add)
(built-in helper)sum - Both use pipeline operator idiomatically
- F# has more list helpers (
,sum
, etc.)average
Pattern 5: Record Updates
Roc:
Person : { firstName : Str, lastName : Str, age : U32, } person = { firstName: "Alice", lastName: "Smith", age: 30 } olderPerson = { person & age: 31 }
F#:
type Person = { FirstName: string LastName: string Age: int } let person = { FirstName = "Alice"; LastName = "Smith"; Age = 30 } let olderPerson = { person with Age = 31 }
Why this translation:
- Roc
operator → F#&
keyword (copy-and-update)with - Roc uses camelCase → F# uses PascalCase (convention)
- Both create new records (immutable)
- F# uses semicolons
for field separators, Roc uses commas;
Pattern 6: Pipeline Operator
Roc:
result = userId |> fetchUser |> validateUser |> saveUser
F#:
// Same pipeline style let result = userId |> fetchUser |> validateUser |> saveUser // Or with composition let processUser = fetchUser >> validateUser >> saveUser let result = processUser userId
Why this translation:
- Both use
for pipeline (identical)|> - F# also has
composition operator (Roc doesn't)>> - Same left-to-right data flow
- F# provides more composition options
Error Handling
Roc Result Model → F# Result/Exception Model
Roc only has
Result. F# supports both Result<'a,'e> and exceptions.
Roc:
divide : I64, I64 -> Result I64 [DivByZero] divide = \x, y -> if y == 0 then Err(DivByZero) else Ok(x // y) when divide(10, 0) is Ok(result) -> Stdout.line!("Result: \(Num.toStr(result))") Err(DivByZero) -> Stdout.line!("Cannot divide by zero")
F# (Result style - preferred):
type DivisionError = DivByZero let divide x y = if y = 0 then Error DivByZero else Ok (x / y) match divide 10 0 with | Ok result -> printfn $"Result: {result}" | Error DivByZero -> printfn "Cannot divide by zero"
F# (Exception style - for interop):
let divide x y = if y = 0 then raise (System.DivideByZeroException()) else x / y try let result = divide 10 0 printfn $"Result: {result}" with | :? System.DivideByZeroException -> printfn "Cannot divide by zero"
Migration strategy:
- Prefer F#
type for functional code (matches Roc semantics)Result - Use exceptions when integrating with .NET libraries
- Convert Roc
to F#when ... ismatch ... with - Tag unions become discriminated unions
Multiple Error Types
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 })
F#:
type ValidationError = | EmptyName | InvalidAge | InvalidEmail type Person = { Name: string Age: int Email: string } let validatePerson name age email = if System.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 }
Why this translation:
- Roc tag unions → F# discriminated unions (direct mapping)
- Roc
→ F#if/else if
(same control flow)if/elif - Both use
with typed errorsResult - Both have exhaustive pattern matching
Async and Effects
Roc Task → F# Async
This is a significant paradigm shift. Roc
Task is platform-provided; F# Async is built into the language.
Roc:
import pf.Http import pf.Task exposing [Task] fetchData : Str -> Task Str [HttpErr] fetchData = \url -> Http.get!(url) processMultiple : List Str -> Task (List Str) [HttpErr] processMultiple = \urls -> urls |> List.map(fetchData) |> Task.sequence main : Task {} [] main = results = processMultiple!(urls) Stdout.line!("Done")
F#:
open System.Net.Http type HttpError = HttpErr of string let httpClient = new HttpClient() let fetchData url = async { try let! response = httpClient.GetStringAsync(url) |> Async.AwaitTask return Ok response with | ex -> return Error (HttpErr ex.Message) } let processMultiple urls = async { let! results = urls |> List.map fetchData |> Async.Parallel return Array.toList results |> List.choose id // Extract Ok values } [<EntryPoint>] let main argv = let urls = ["url1"; "url2"; "url3"] processMultiple urls |> Async.RunSynchronously |> ignore printfn "Done" 0
Why this translation:
- Roc
→ F#Task a err
(effects + errors)Async<Result<'a, 'err>> - Roc
operator → F#!
inlet!
blockasync { } - Roc platform handles execution → F# needs
Async.RunSynchronously - Roc
is a Task → F#main
returns int (exit code)main
Pure vs Effectful Code
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)
F#:
// Pure computation let add x y = x + y // Effectful computation (no special type required) let greet name = printfn $"Hello, {name}!" name // Can return pure value directly // Or as Async if needed let greetAsync name = async { printfn $"Hello, {name}!" return name }
Migration strategy:
- Roc
functions → F#Task
or direct I/O (depends on context)Async - Roc pure functions → F# pure functions (direct mapping)
- Roc
try operator → F#!
orlet!do! - Separate pure logic from effects for clarity
Platform Architecture
Roc Application + Platform → .NET Application
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) Stdout.line!(processed) processInput : Str -> Str processInput = \input -> Str.toUpper(input)
F# (.NET Console App):
[<EntryPoint>] let main argv = let input = System.Console.ReadLine() let processed = processInput input System.Console.WriteLine(processed) 0 // Return exit code let processInput input = input.ToUpper()
Key differences:
- Roc entry point is a
that platform executesTask - F# entry point is a function that returns int (exit code)
- Roc separates pure from effectful code; F# can mix them
- Roc uses platform imports; F# uses .NET BCL directly
Common Pitfalls
-
Forgetting F# allows mutability
- Roc has no mutable variables
- F# allows
keyword andmutable
cellsref - Benefit: Can use mutable state when performance-critical
-
Not leveraging F# exceptions
- Roc only has
typeResult - F# has both
and exceptionsResult - Strategy: Use
for domain errors, exceptions for unexpected failuresResult
- Roc only has
-
Missing .NET BCL libraries
- Roc only has what the platform provides
- F# has access to entire .NET ecosystem
- Benefit: Rich library support (LINQ, JSON.NET, Entity Framework, etc.)
-
Not using F# computation expressions
- Roc uses pattern matching and
operator! - F# has
,async { }
,result { }
, etc.seq { } - Strategy: Use computation expressions for cleaner code
- Roc uses pattern matching and
-
Ignoring F# type providers
- Roc has no metaprogramming
- F# has type providers for compile-time code generation
- Benefit: Can generate types from SQL, JSON, CSV at compile time
-
Assuming strict platform/application split
- Roc strictly separates pure (app) from effects (platform)
- F# mixes pure and impure code freely
- Strategy: Maintain separation for clarity, but leverage flexibility
-
Not using F# units of measure
- Roc has no built-in units
- F# has
for type-safe calculations[<Measure>] - Benefit: Compile-time dimension checking
-
Missing F# Interactive (REPL)
- Roc has limited REPL support
- F# has FSI for interactive development
- Benefit: Rapid prototyping and exploration
Module System
Roc Interfaces → F# Modules/Namespaces
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
F#:
// User.fs namespace MyApp module User = type User = { Id: int64 Name: string Email: string } let create name email = { Id = generateId() Name = name Email = email } let getName user = user.Name
Migration notes:
- Roc interfaces → F# modules or namespaces
- Roc
is explicit → F# exports everything by defaultexposes - Roc file-based modules → F# file order matters in .fsproj
Build System
Roc Application → .NET 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:
roc build main.roc roc run main.roc
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>
Build commands:
dotnet build dotnet run
Key differences:
- Roc infers dependencies from imports
- F# needs .fsproj and explicit file ordering
- Roc uses platform URLs; F# uses NuGet packages
- F# has richer build tooling (watch mode, publish, etc.)
Testing
Roc Expect → F# Testing Frameworks
Roc:
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:
roc test main.roc
F# (Expecto):
module Tests open Expecto let add x y = x + y [<Tests>] let tests = testList "Math tests" [ testCase "addition" <| fun () -> Expect.equal (add 2 2) 4 "2 + 2 = 4" testCase "division by zero" <| fun () -> let result = divide 10L 0L Expect.equal result (Error DivByZero) "should error" testCase "division success" <| fun () -> let result = divide 10L 2L Expect.equal result (Ok 5L) "10 / 2 = 5" ] [<EntryPoint>] let main args = runTestsWithCLIArgs [] args tests
Run tests:
dotnet test
Migration strategy:
- Convert Roc
statements to test framework assertionsexpect - Group related expects into test lists
- F# has richer testing tools (Expecto, xUnit, FsUnit, FsCheck)
Examples
Example 1: Simple - Record and Pattern Matching
Before (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"
After (F#):
type User = { Id: int64 Name: string Email: string } let users = [ { Id = 1L; Name = "Alice"; Email = "alice@example.com" } { Id = 2L; Name = "Bob"; Email = "bob@example.com" } ] let findUserById id = users |> List.tryFind (fun u -> u.Id = id) let getUserName id = match findUserById id with | Some u -> u.Name | None -> "Unknown"
Example 2: Medium - Result with Multiple Errors
Before (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 })
After (F#):
type ValidationError = | InvalidName | InvalidAge type Person = { Name: string Age: int } let validateName name = if System.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 } }
Example 3: Complex - Task-based File Processing
Before (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 -> content = readFile!(inputPath) processed = processContent!(content) 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")
After (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) | ex -> return Error (FileNotFound $"Error: {ex.Message}") } let processContent content = if content.Contains("error") then Error (InvalidFormat "Content contains error") else Ok (content.ToUpper()) let writeFile path content = async { try do! File.WriteAllTextAsync(path, content) |> Async.AwaitTask return Ok () with | ex -> return Error (FileNotFound $"Write error: {ex.Message}") } 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 } [<EntryPoint>] let main argv = let result = processFile "input.txt" "output.txt" |> Async.RunSynchronously match result with | Ok () -> printfn "File processed successfully" | Error (FileNotFound msg) -> printfn $"File error: {msg}" | Error (InvalidFormat msg) -> printfn $"Invalid format: {msg}" 0
Key conversions:
- Roc
→ F#Task a errAsync<Result<'a, 'err>> - Roc platform I/O → F# direct file I/O with .NET APIs
- Roc
operator → F#!
in async blockslet! - Roc
returns Task → F#main
returns intmain
See Also
For more examples and patterns, see:
- Foundational patterns with cross-language examplesmeta-convert-dev
- Reverse conversion (F# → Roc)convert-fsharp-roc
- Roc development patternslang-roc-dev
- F# development patternslang-fsharp-dev
Cross-cutting pattern skills:
- Task model vs Async workflowspatterns-concurrency-dev
- JSON, validation across languagespatterns-serialization-dev
- No metaprogramming vs type providerspatterns-metaprogramming-dev