Stream: beginners

Topic: from a newcomer: Roc vs. F# comparison


view this post on Zulip BearCarpenter (Sep 03 2026 at 14:43):

I was learning Roc recently and thought it would be a fun exercise to write a small script that goes through a bunch of directories and gets the repository URL from each one (git -C directory remote -v).
I started with Roc, and then decided to write the same thing in F#. I’m also a beginner in F#, although I have a background in ASP.NET/C# and TypeScript. So I ended up writing - or rather, trying to write - essentially the same program in two languages.

Roc

The first surprise for me was error handling.
I initially assumed that errors would naturally “compose”, but that is not always the case. For example, not every function returns an open error type such as:

to_str : Path -> Try(Str, [InvalidStr(U64)])

So I ran into:

The error types from all ? operators and the function body must be compatible since any of them could be the actual return value.

I had to bring the different errors to a common denominator. After a few misunderstandings I managed to sort it out, but it took me a while to understand what Roc was asking me to do.
At first I imagined writing the whole thing in a very pipeline-heavy style using |>. Eventually I discovered that combining ? and |> could mean adding extra parentheses in places where I didn’t really want them, so I started writing the code in a somewhat more imperative style.
One thing I really liked was the documentation experience. I could usually figure out where to look and what I should be searching for.
VS Code completion with Ctrl+Space was less helpful than I expected. I was using:

"roc-lang.language-server.exe": "/usr/local/bin/roc experimental-lsp"

but it was still good enough to get the job done.
There were also several small things that surprised me as a beginner. I kept wanting to write () where Roc expected ||. I was also instinctively searching for filter, coming from TypeScript, and had to learn that the function is called keep_if.
Overall, though, it wasn't nearly as difficult as I expected.
What I liked most was that I had to deal with every error. Even if I eventually decided to ignore or simplify something, it felt like I was doing so consciously and explicitly. Roc wasn't silently hiding quite as much from me.
I also really like the ad-hoc union variants. They make error handling feel lightweight without forcing me into a large hierarchy of types.

F

Then I thought: let's do exactly the same thing in F#.
I decided to follow the same rule and not use AI. I started with searches like "F# io" and "F# list directories", but somehow nothing immediately led me to System.IO.
That made it surprisingly difficult to find the API I needed, especially considering how mature the .NET ecosystem is and how much documentation exists.
At one point my thought was basically: I just want to list some directories, and already I feel like I’m dealing with an ecosystem that doesn’t make this particularly easy.
Then I noticed that nothing really encouraged me to do the equivalent of a fast return with an Error monad, so just to keep moving I ended up using exceptions with failwith.
The API for running a command also genuinely frightened me a little.
I found several different approaches to starting a process, plus libraries such as ProcessKit that provide an API much closer to the style I was looking for. But I wanted to do it using the native APIs.
And there was another thing I found quite interesting: with methods such as ReadToEnd, the API can throw exceptions, but I didn't consciously account for that while writing the code. I only noticed it later in the documentation/tooling.
So throughout the F# version I had a much stronger feeling that something might be happening underneath that I’m not aware of.
Formatting also felt more irritating, and there were more places where I wasn't completely sure whether what I had written was actually safe/correct.

My overall impression

After doing the same small task in both languages, I had a much stronger feeling in Roc that I understood what was happening.
Roc gave me less of a feeling that there was some kind of magic happening underneath the code.
I also simply wrote the Roc version faster and enjoyed the process more.
I prefer two spaces over four, so I just changed:

"editor.tabSize": 2

in VS Code.
With F# I suspect I would need an .editorconfig for Fantomas to pick that up.
Curly braces don't bother me in Roc. What did annoy me slightly was explicitly forcing types: writing things like:

variableName : ...

and sometimes copying the inferred type hints from the editor just to paste them back into the code.
On the other hand, one feature I absolutely loved was Pattern Matching with String Interpolation. I only discovered it because I read about it in the Roc from Rust to Zig blog post. :D
I'm hoping that over time the Roc ecosystem will get things like WASM + MVU and perhaps a good framework for desktop and mobile applications as well.
I don't really have anyone around me to discuss Roc with, so I thought I'd post this here and see whether anyone has similar experiences. And if not, that's fine too.
Also: congratulations on how much has already been built.
I really enjoy listening to Richard Feldman explain why Roc made certain design decisions. I think it's worth continuing to expand the FAQ in that direction. Other languages often don't provide this kind of reasoning nearly as explicitly. The idea that a language can deliberately have fewer features because having more of them would make some mathematical proof of correctness impossible is a particularly compelling argument to me.
It also feels like Roc is currently competing, at least in some respects, with MoonBit, which is developing very quickly too.
For now, though, I'm betting on Roc.
Roc:

app [main!] { pf: platform "https://github.com/roc-lang/basic-cli/releases/download/0.22.0/F1JVZPYfWP71s8vk6tHcV1Qx1Ef6CZkwswGoCn8VHZmL.tar.zst" }
import pf.OsStr
import pf.Stdout
import pf.Path
import pf.Cmd
main! : List(OsStr) => Try({}, _)
main! = |_args| {
  githubSites = readAllGithub!() ? |err| Err(err)
  Stdout.line!(Str.inspect(githubSites)) |> Try.ok_or({})
  Ok({})
}
readAllGithub! : () => Try(List(Str), _)
readAllGithub! = || {
  workspace : Path
  workspace = "."
  directories : List(Path)
  directories = (workspace.list!() ? |_| ErrorWorkspaceList)
    .keep_if_try!(Path.is_dir!) ? |_| ErrorPathIsDir
  paths : List(Str)
  paths = directories.map_try(Path.to_str) ? |_| ErrorPathToStr
  outputs = paths.map_try!(
    |path| {
      # git -C directory remote -v
      Cmd.new("git").args_str(["-C", path, "remote", "-v"]).exec_output!()
        |> Try.map_ok(|c| c.stdout_utf8)
        |> Try.map_err(|_| ErrorWithDir(path))
    },
  )?
  sites : List(Str)
  sites = List.map(
    outputs,
    |o| {
      o.split_on("\n")
    },
  )
    .join()
    |> List.map(
      |a| {
        f = match a {
          "origin${_}https://${site} (fetch)" => site
          _ => ""
        }
        f
      },
    )
    |> List.drop_if(Str.is_empty)
  Ok(sites)
}

F#:

open System.IO
open System.Diagnostics
open System.Collections.Generic
let readAllGithubs () =
    let sourceDirectory = __SOURCE_DIRECTORY__
    let directories =
        try
            Directory.GetDirectories sourceDirectory
        with _ ->
            failwith "problem with Directory.GetDirectories"
    let readGithub (d: string) : string =
        // git -C directory remote -v
        let procStartInfo =
            ProcessStartInfo("git", $"-C {d} remote -v", RedirectStandardOutput = true, UseShellExecute = false)
        let outputs = List<string>()
        let errors = List<string>()
        let outputHandler f (_sender: obj) (args: DataReceivedEventArgs) = f args.Data
        let p = new Process(StartInfo = procStartInfo)
        p.OutputDataReceived.AddHandler(DataReceivedEventHandler(outputHandler outputs.Add))
        p.ErrorDataReceived.AddHandler(DataReceivedEventHandler(outputHandler errors.Add))
        use proc = Process.Start procStartInfo
        let stdout = proc.StandardOutput.ReadToEnd()
        do proc.WaitForExit()
        if proc.ExitCode <> 0 then
            eprintfn $"git failed with {proc.ExitCode}"
            failwithf "Failed to start process in %s" d
        else
            ()
        stdout
    directories
    |> Array.map readGithub
    |> Array.map (fun o -> o.Split '\n')
    |> Array.concat
    |> Array.map (fun a ->
        match a with
        | s when s.StartsWith "origin" && s.Contains "https://" && s.EndsWith " (fetch)" ->
            let start = s.IndexOf "https://" + "https://".Length
            let length = s.Length - start - " (fetch)".Length
            s.Substring(start, length)
        | _ -> "")
    |> Array.filter (fun site -> not (Seq.isEmpty site))
printfn "%A" (readAllGithubs ())

Last updated: Sep 03 2026 at 15:16 UTC