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

view this post on Zulip Aurélien Geron (Sep 03 2026 at 21:24):

Very interesting feedback, thanks!

I agree that the open/closed tag union mix in errors is currently confusing, but it's being worked on (unions in output positions will automatically be open), so hopefully it will be fixed soon.

Can you give some examples of cases where you had to explicitly force types? It only happens to me when I have a bug in my code and the error message isn't too clear, so I have to write the type spec to understand what the problem is. Is that what you are referring to, or is the type spec really needed? One case I can think of is for JSON parsing, where I need the type spec to specify what schema I'm expecting. Or when I have to write Bool.True or Try.Ok instead of Bool or Ok because otherwise the compiler doesn't understand that I'm referring to a Bool or a Try.

view this post on Zulip BearCarpenter (Sep 04 2026 at 05:36):

Yes, I think I was referring more to the development process than to type annotations being strictly required by the compiler.

I tend to add type constraints at points where I know what result I want, but I don't necessarily know yet how to get there with my implementation. For example, I might know that I want to end up with a List(Str), so I add that constraint to force myself to reason about whether my implementation is actually producing the type I intend.

So it's not really:

"The compiler requires me to write this."

It's more:

"I need to impose this constraint on myself while developing, so I can be confident about what this function is supposed to return."

And that's where I sometimes ended up copying the inferred type hint from the editor and turning it into an explicit signature. I probably described this a bit too strongly in my original post as "forcing types".

More broadly, though, I wanted to describe what felt exotic to someone coming from a fairly C-like, corporate-but-mainstream background in C#/TypeScript.

Things like || instead of (), the fact that an arrow function doesn't have an arrow, and especially the fact that understanding why function signatures are written above the implementation is important.

Initially, when I saw:

foo : Str -> List(Str)
foo = |value| {
    ...
}

my C-like brain was basically going:

"Why on earth do I have to write this separately?"

It felt like a rather quirky design decision.

But after watching some of @Richard Feldman 's talks about things like extreme type inference, tagged unions, and the absence of an Option monad, the reasoning started to make much more sense to me. In that context, the signature placement feels like a pretty reasonable trade-off.

So this is actually another reason why I think the FAQ/ADR idea is valuable. Richard doesn't just explain what Roc does — there is a lot of pragmatic reasoning behind why it was designed that way.

For example, I'd love to be able to read something like:

"We put type signatures above definitions because X, Y and Z. Given Roc's type system and level of inference, this is the trade-off we chose."

For someone coming from C#/TypeScript, that can turn a decision that initially looks completely bizarre into something that actually feels quite well motivated.

The only part that still feels slightly unergonomic to me is having to repeat the variable/function name in the signature:

foo : ...
foo = ...

But I can also see why the overall design benefits from having the signature separated from the implementation.

And one other thing I was trying to communicate with the original post — especially for people coming from outside Roc — is the difference in the kind of final result I spontaneously arrived at.

These were both basically my second little program in each language, written without AI or a predefined architecture. Yet the F# version naturally led me towards leaving more things implicit and more potential failure cases unhandled. I ended up with exceptions, process-handling details I hadn't initially accounted for, and other safety gaps that I only noticed afterwards.

In a real code review, someone might quite reasonably tell me to go back and fix those things.

With Roc, the type/error system pushed me to confront many of those cases while I was writing the program. Even when I ultimately chose to simplify or ignore something, I had to make that decision explicitly.

So I wasn't really trying to say "Roc requires more type annotations than F#". It was more that Roc changed my development process and gave me a stronger feeling that the program's invariants were visible while I was writing it.

And that difference was probably the most interesting part of the experiment for me.

view this post on Zulip Aurélien Geron (Sep 04 2026 at 06:24):

Interesting. Regarding the type spec on a separate line, I may be wrong but perhaps Roc got some inspiration from Haskell. In Haskell, here's how you can define the square function:

square :: Num a => a -> a
square x = x * x

view this post on Zulip Aurélien Geron (Sep 04 2026 at 06:34):

In Haskell, it has the benefit of allowing a definition to be chopped into separate cases, for example:

map :: (a -> b) -> [a] -> [b]
map _ []     = []
map f (x:xs) = f x : map f xs

A direct translation to Roc might look like this:

map : (a -> b), List(a) -> List(b)
map = |func, list| match list {
    [] => []
    [x, .. as xs] => [func(x)].concat(map(func, xs))
}

Although we would probably prefer to put the list first to use list |> map(...), and the function could be optimized (e.g., by reserving capacity for the list and appending one element at a time).

Having one line for the spec and one for the definition feels a bit weird at first, but I like the fact that I can write code without type specs and add them later, or comment out a type spec easily. I guess I got used to it.

view this post on Zulip BearCarpenter (Sep 04 2026 at 08:25):

Ah, I didn't know the separate type-spec line was inspired by Haskell.

F# also has Hindley–Milner type inference, but when you need an explicit type annotation, you generally add it on the same line as the definition. I think that's a little more intuitive coming from C-like languages.

That said, in the context of Roc, where the type signatures can become quite long and expressive, I can see why having them above the implementation makes more sense. It also makes the distinction between "what this function accepts/returns" and "how it is implemented" much clearer.

One other small thing I noticed while working in VS Code: Go to Definition seems to take me to the file rather than to the specific function or type definition. Maybe this is just a limitation of the current LSP, but coming from C#/TypeScript I noticed it quite quickly.


Last updated: Sep 24 2026 at 15:59 UTC