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