← Back to context

Comment by 110bpm

9 months ago

For me, the closest language currently is F#.

The open-source ecosystem is not as massive as Go's or the JVM's, but it's not niche either. F# runs on .NET and works with all .NET packages (C#, F#, ...). If the .NET ecosystem can work out for you, I recommend taking a closer look at F#.

F# allows for simple code, which is "functional" by default, but you're still free to write imperative, "side-effectful" code, too.

I find this pragmatic approach works extremely well in practice. Most of my code ends up in a functional style. However, once projects grow more complex, I might need to place a mutable counter or a logging call in an otherwise pure function. Sometimes, I run into cases where the most straightforward and easy to reason about solution is imperative.

If I were confined to what is often described as a "pure functional" approach, I'd have to refactor, so that these side-effects would be fully represented in the function signature.

F# ticks the enums/ADTs/pattern box but also has its own interesting features like computation expressions [0]. I would describe them as a language extension mechanism that provides a common grammar (let!, do!, ...) that extension writers can target.

Because of this, the language doesn't have await, async or any other operators for working with async (like C# or TS). There's an async {} (and task {}) computation expression which is implemented using open library methods. But nothing is preventing you from rolling your own, or extending the language with other computation expressions.

In practice, async code looks like this:

  let fetchAndDownload url =
    async {
        let! data = downloadData url // similar to C#: var data = await downloadData(url);

        let processedData = processData data

        return processedData
    }

I often use taskResult{}/asyncResult{}[1] which combine the above with unwrapping Result<>(Ok or Error).

Metaprogramming is somewhat limited in comparison to Scala or Haskell; but still possible using various mechanisms. I find that this isn't a big issue in my work.

IDE-wise, JetBrains Rider is a breeze to work with and it has native F# support. There is also Visual Studio and VS Code with Ionide, which are better in some areas.

You can run F# in Jypiter via .NET Interactive Notebooks (now called "Polyglot Notebooks" [2]). I haven't seen this mentioned often, but this is very practical. I have a combination of Notebooks for one-off data tasks which I run from VS Code. These notebooks can even reuse code from my regular F# projects' code base. Over the past years, this has almost eliminated my usage of Python notebooks except for ML work.

[0]: https://learn.microsoft.com/en-us/dotnet/fsharp/language-ref...

[1]: https://github.com/demystifyfp/FsToolkit.ErrorHandling?tab=r...

[2]: https://marketplace.visualstudio.com/items?itemName=ms-dotne...

Do you know of or have any shareable (sample) projects implemented in your way of doing F#? It sounds very intriguing to me

  • While the libraries and techniques I mentioned above seem to be well-known, I couldn't find a good public sample project.

    I can recommend https://fsharpforfunandprofit.com/ as a starting point.

    If there's interest, I can split some of my code into stand-alone chunks and post my experience of what worked well and what didn't.

    I wanted to share some thoughts on here on what brought me to F#. Maybe this can serve as a starting point for people who have similar preferences and don't know much about F# yet.

    A big part that affects my choice of programming language is its type system and how error handling and optionality (nulls) are implemented.

    That "if it compiles, it runs" feeling, IMO, isn't unique to Rust, but is a result of a strong type system and how you think about programming. I have a similar feeling with F# and, in general, I am more satisfied with my work when things are more reliable. Avoiding errors via compile-time checks is great, but I also appreciate being able to exclude certain areas when diagnosing some issue.

    "Thinking about programming and not the experience" the author lamented in the blog post appears to be the added cost of fitting your thoughts and code into a more intricate formal system. Whether that extra effort is worth it depends on the situation. I'm not a game developer, but I can relate to the artist/sound engineer (concept/idea vs technical implementation) dichotomy. F#'s type system isn't as strict and there are many escape hatches.

    F# has nice type inference (HM) and you can write code without any type annotations if you like. The compiler automatically generalizes the code. I let the IDE generate type annotations on function signatures automatically and only write out type annotations for generics, flex types, and constraints.

    I prefer having the compiler check that error paths are covered, instead of dealing with run-time exceptions.

    I find try/catches often get added where failure in some downstream code had occurred during development. It's the unexpected exceptions in mostly working code that are discovered late in production.

    This is why I liked Golang's design decisions around error handling - no exceptions for the error path; treat the error path as an equal branch with (error, success) tuples as return values.

    Golang's PL-level implementation has usage issues that I could not get comfortable with, though:

      file, err := os.Open("filename.ext")
      if err != nil { return or panic }
      ...
    

    Most of the time, I want the code to terminate on the first error, so this introduces a lot of unnecessary verbosity.

    The code gets sprinkled with early returns (like in C#):

      public void SomeMethod() {
      if (!ok) return;
      ...
      if (String.IsNullOrEmpty(...)) return;
      ...
      if (...) return;
      ...
      return;
      }
    

    I noticed that, in general, early returns and go-tos introduce logical jumps - "exceptions to the rule" when thinking about functions. Easy-to-grasp code often flows from input to output, like f(x) = 2*x.

    In the example above, "file" is declared even if you're on the error path. You could write code that accesses file.SomeProperty if there is an error and hit a null ref panic if you forgot an error check + early return.

    This can be mitigated using static analysis, though. Haven't kept up with Go; not sure if some SA was baked into the compiler to deal with this.

    I do like the approach of encoding errors and nullability using mutually exclusive Result/Either/Option types. This isn't unique to F#, but F# offers good support and is designed around non-nullability using Option types + pattern matching.

    A possible solution to the above is well explained in what the author calls "railway oriented programming": https://fsharpforfunandprofit.com/posts/recipe-part2/.

    It's a long read that explains the thinking and the building blocks well.

    The result the author arrives at looks like: let usecase = combinedValidation >> map canonicalizeEmail >> bind updateDatebaseStep >> log

    F# goes one step further with CEs, which transform this code into a "native" let-bind and function call style. Just like async/await makes Promises or continuations feel native, CEs are F#'s pluggable version of that for any added category - asynchronicity, optionality, etc..

    With CEs, instead of chaining "binds", you get computation expressions like these: https://demystifyfp.gitbook.io/fstoolkit-errorhandling/fstoo... https://demystifyfp.gitbook.io/fstoolkit-errorhandling/fstoo...

    Everything with an exclamation mark (!) is an evaluation in the context of the category - here it's result {} - meaning success (Ok of value) or error (Error of errorValue). In this case, if something returns an Error, the computation is terminated. If something returns an Ok<TValue>, the Ok gets unwrapped and you're binding TValue.

    I have loosely translated the above example into CE form (haven't checked the code in an editor; can't promise this compiles).

      let useCase (input:Request) =
       result {
          do! combinedValidation |> Result.ignore
          // if combinedValidation returns Result.Error the computation terminates and its value is Result.Error, if it returns Ok () we proceed
          let inputWFixedEmail = input |> canonicalizeEmail
          let! updateResult = updateDatabaseStep inputWFixedEmail // if the update step returns an Error (like a db connection issue) the computation termiantes and its value is Result.Error, otherwise updateResult gets assigned the value that is wrapped by Result.Ok
          log updateResult |> ignore // NOTE: this line won't be hit if the insert was an error, so we're logging only the success case here
          return updateResult
       }
    

    In practice, I would follow "Parse, don't validate" and have the validation and canonicalizeEmail return a Result<ParsedRequest>. You'd get something like this:

      let useCase input =
       result {
          let! parsedUser = parseInput input
          let! dbUpdateResult = updateDatabase parsedUser 
          log dbUpdateResult |> ignore
          return updateResult
       }
    
      let parseInput input =
       result {
          let! userName = ...
          ...
          return { ParsedRequest.userName = userName; ... } // record with a different type
       }
    

    This setup serves me well for the usual data + async I/O tasks.

    There has been a range of improvements by the F# team around CEs, like "resumable state machines" which make CEs execute more efficiently. To me this signals that CEs are a core feature (this is how async is supposed to be used, after all) and not a niche feature that is at risk of being deprecated. https://github.com/fsharp/fslang-design/blob/main/FSharp-6.0...

    • Thanks so much for your detailed reply. This looks very cool indeed. I've had a couple tiny projects in F# in the past that never went anywhere, but you're describing essentially all the parts in a programming language that I want, early returns, binds/maps, language support for these features, defining your own keywords (not really but kinda with your expressions)

      Excited to try this out