Hacker News new | ask | show | jobs
by tejasv 2203 days ago
We, at Chaldal (YC S15, chaldal.tech), use F# in production. Most of our backend projects are in F#, the last one is being migrated over slowly from C#. Our new frontend projects are also in F# (using Fable).

I want to share some insights of using F# with the community. We started from a C# codebase, and realized that a better language can help weed out most bugs in our system. And yes, it works. Pretty much all bugs we face these days are parts where the F# world touches something non-F#, like .NET and other libraries written for C#, where interaction (like nulls) is not well-defined. We've taken Scott Wlaschin's (fsharpforfunandprofit.com) teachings to heart, and we have a giant banner in our office that reads "Make illegal states unrepresentable".

It took a bit of learning for everyone to jump in, but our dev team has loved the experience as the language is a pleasure to use; when they need to go back to write C# or TypeScript code, a lot of these learnings transfer. People just become better programmers (as is true with learning any functional language).

To get all the benefits of F#, you must adopt the whole paradigm. While F# allows C#-like OOP, and while this can be an initial stepping stone in the path towards F#, you must go all the way. If you simply do OOP, the trade-offs aren't worth it, IMO, as F# is a functional-first language, and the OOP is mostly provided for interop with the rest of .NET.

IDE support has been janky in the past, but its improving. Latest VS 2019 is pretty good, and JetBrains Rider works pretty well on the Mac.

3 comments

I get the "Make illegal states unrepresentable" in theory but how do you do it actually for nontrivial preconditions like "this list must be sorted"? (as a precond for a binary search on a vector, for example).

OOP is fine with functional if you make it immutable too, so I don't see the problem (I've not really got a problem with mutable OOP, or state generally, if it's done carefully).

> how do you do it actually for nontrivial preconditions like "this list must be sorted"?

This depends on your data model, of course. But for a list of integers you could do the following:

1. Have a unique data type that is the list of Deltas, plus the initial element (So for instance the list [5, 3, 6] would be encoded as (3, [2, 1]).

2. Provide a sort function that creates such a sorted list.

3. Make the sorted list your input parameter.

This is obviously oversimplified, but I hope you get the idea.

A cheaper alternative is to

1. Make an opaque datatype "sorted list", together with a function that translates this type to a normal list. Implemen this type just as a list, but keep the implementation private.

2. Provide a function sort, that is the only function that can yield that type.

3. Demand that type as input.

Encapsulation aided by types and module interfaces.

In Reason/OCaml, you can create a module interface (.rei or .mli) which makes the type opaque to functions outside the module. So instead of saying `type t('a) = list('a)`, it'll just say `type t`.

The compiler relies on this type information to decide whether functions are well-typed. So if an external module presumes to know the structure of the type and does an operation on it, it becomes a type error because the compiler simply doesn't know about the actual type. This is similar to encapsulation in OO where we don't expose a field to the outside world, but here it is done by virtue of the type signature itself, which I found to be a more powerful guarantee of encapsulation.

The module can then expose functions like `append` which would be the only way you can manipulate the list. This function in-turn can ensure the postcondition, guaranteeing that the list stays sorted. At the point in which we want to use the list for functions not supported by our SortedList module, we can turn it into a regular list with a `to_list` function. Since the underlying type is already a list, the operation is virtually free. The function would look like `let to_list = (xs: t('a)) => (xs: list('a))`. It is an identity function, with just the type changed for the compiler.

Similar rules about `append` applies to the constructor: we can create a new `SortedList` only with a `SortedList.make` which can ensure the postcondition. There will be no other way, thanks to the type being hidden, to create a value of the `SortedList.t` type.

I would use the “smart constructor” pattern for this: https://draptik.github.io/posts/2020/02/10/fsharp-smart-cons...

Things like that require runtime logic, there’s no way around that.

It's idiomatic to use "opaque types" to achieve this in F#. In practice it is actually pretty analogous to the concept of a "constructor" in OOP.
The difference between doing functional programming in C# vs doing functional programming in F# is subtle but important. A true FP language like F# will not allow code that introduces certain classes of errors FP is designed to prevent. Whereas in C#, a developer needs to be really careful not to introduce those errors even when following FP style. C# will always allow mutable states, null values, global variables, to name a few.
As other replies to this comment describe, we make heavy use of opaque types where runtime enforcement of safety is need. Some examples: PositiveFloat, PositiveDecimal, NonEmptyMap, NonEmptySet, NonEmptyList, KeyedSet.
> While F# allows C#-like OOP, and while this can be an initial stepping stone in the path towards F#, you must go all the way.

I'd be less charitable and say that OOP in F# is a tedious, verbose mess. Functional programming in F# is extremely concise, at the expense of OOP. I wrote a parser/lexer in F#, to be used from C#, and ended up rewriting it in C# instead of dealing with the interop. I'm hoping that some day, C# will eventually have F#'s pattern matching power. They're slowly introducing more and more features to do it, and there's no major technical reason why C# wouldn't be able to.

The way we do it at $WORK is to do a proper F# API and then expose a fluent C# API that wraps it. Our APIs are usually based on constructing descriptions - which a fluent API can do just as well as a "native" F# one - and then we provide a very few functions which interpret those descriptions. The hard interop is in constructing the descriptions, because C# likes objects to look very different from F#; we just give up and expose two separate APIs for constructing the F# objects.
Agreed. You can't completely avoid it as we're running on a framework (and libraries) that's based on C#, but we architect our code such that these messy edges that interface with the outside world are implemented once, creating a nice walled garden for the rest of the F# code inside.
> "Make illegal states unrepresentable" > (as is true with learning any functional language)

...unless you are using a language where types are not part of the contract and not used in dispatching, such as Elixir.

I have mixed feelings about that because, on the other hand, in runs on the BEAM VM and as long as lack of typing leads to a proper crash, you may be OK as long as your code is written correctly.