How I got interested in F#
Date: 2024-01-15 | create | tech | fsharp | career |
I get asked a lot about what got me into F# and why I use it over other languages. I have specific reasons but a lot of those reasons are really rooted in my journey and experience with other languages and technologies over the years.
So in this post I'm going to share a brief look into my technology journey and what I learned before eventually landing on F# as a core part of my tech stack.
If you're interested in getting started with F# yourself, checkout The best way to get started learning and building with F#.
C# and the status quo
In college we mostly had classes in Java and Python. I learned Javascript on the side to build fullstack projects that I could share with others via the web. My first job out of college was at a C# shop which was a hard but good switch.
C# has a lot of things going for it - IMO it's a better version of Java. It's got good types (for a mainstream OO language), a large ecosystem, and a very solid runtime making it a pretty good choice for many different kinds of applications.
So for all intents and purposes this is a good language - both in isolation and relative to the rest of the field. It does many things well, often better than most.
And yet it has many of the shortcomings that most languages have had over the last several decades:
- Everything can be null - The billion dollar problem that leads to things breaking when we think they're safe and implicit null checks EVERYWHERE to try (and inevitably fail) to prevent this because we're scared the types are lying to us (they are). Yes, they've since added in strict null checking but many codebases haven't upgraded yet.
- Exception-based control flow - aka deer-in-the-headlights panic and die by default.
- No native discriminated unions (i.e. this is A or B) - This almost always leads to unnecessary complexity to try and make this work in a language that doesn't support this. And this is always something we try to do because this happens in real life all the time - we can accept CASH or CREDIT, I will write with a PEN or a PENCIL, my cart is either EMPTY or NOT_EMPTY. If you don't believe me - think of the last time you used an enum - you're probably trying to make a DU. So we do interesting things to approximate this missing functionality:
- Interface implementors lie - We create interfaces so that functions can code to the interface and "not care" about the underlying implementation. This allows you to shoe-horn multiple things through the same "type". But often that interface is not a snug fit for the underlying types so some types may need to have placeholder returns, passes, or NotImplementedExceptions to "fill" the interface thus lying about what they do.
- Interface users must reach into internals to know what they're working on - While in theory an interface means you don't need to know what the implementation is doing, in practice you often do to use them reasonably. Is this a bicycle or a motorbike? Is this a duck or a dog? Is this an authenticated request or unauthenticated request or admin request? We often do this via type identifiers in the form of an enum string field or in the case of Python, checking the underlying type.
- Strategy patterns instead of interface - Which really means lots of implicit logic that makes it impossible for you to know what I'm doing and therefore many possible ways I implicitly break
Yes there are ways around these and ways to do these "right" but if a thing regularly breaks in the same ways, you gotta wonder if it's the user's fault or maybe the thing just isn't designed very well.
Regardless this is not a bash on C#. IMO C# is one of the better options. But it is to show that the status quo, while popular, is not without its downsides. I could easily list more issues and other languages with similar issues - Java being the most similar, Golang and its implicit nils / garbage and lack of type narrowing, C/C++ and unsafe memory, Rust and requisite boilerplate, etc etc.
C# isn't all bad
Now one thing I really did like about C# that I hadn't seen before and missed in a lot of other languages was LINQ (with method syntax). It's essentially type-safe chain operations on lists - select, where, single, first, orderby, etc. Kind of like JS's map, filter, etc but with lots of niceties built-in.
I found this paradigm of programming to be super easy to work with in terms of ease-of-coding and correctness. This got me trying to write more of my code in a LINQ-like fashion and wondering if there were ways to do this more easily for more kinds of programming.
So I liked C# and its ecosystem. I liked how it was good at so many things and I really loved it when I could code with LINQ. But every program still felt like a burden with lots of boilerplate and mental overhead to get it to do what I wanted without falling into the numerous implicit pits of failure.
TypeScript and good types
So C# was the main language I used and learned in the early years of my career for work and side projects. But I was also dabbling in TypeScript for frontends and fullstack apps. Frontend web was (and largely still is) dominated by heavyweight client frameworks like React so Javascript / Typescript was the native tongue.
There's a lot of things I don't like about Javascript but I wouldn't bet against it - it's the language of the web and the web only gets more ubiquitous. TypeScript solved a lot of the common issues I ran into when working on Javascript codebases, mostly by adding types. IMO static types (with dev-time type checking) is the single-most impactful thing for improving dev speed at scale and this added it.
Now Typescript does not solve all the problems of JavaScript. It is still JS under the hood after all and TS even adds complexity due to its need for extra build steps (which were much more complex / less stable to setup than they are today). This means that sometimes the types are a lie because JS said something was A but really it was B.
And you still have to deal with all the unstable, half-baked libraries out there which regularly lost stability every few months as the ecosystem switches to a new runtime or new build system. There's a huge amount of options but you'll also have to try a half-dozen before finding one that works for a few months.
But I've got to say that TypeScript types are very good! Especially when coming from OO land these types feel flexible and powerful.
- You can declare unions - this thing is A or B
- You can declare static strings - On A the string will be "a", on B the string will be "b"
- You can easily differentiate between the unions based on what it has - if "a" -> A, if "b" -> B (aka you can discriminate which one!)
All of this made modeling my domain with types feel much better. It gave me the power to model my domain at scale with the flexibility to do so with precision so I wasn't bending over backwards trying to reconstruct a union. It just worked.
Alas while I enjoyed all of these benefits while in TypeScript land, I did not enjoy it when I had to step out to integrate with the rest of the JS ecosystem. This was largely necessary at the time to get setup with a server to host your code, use npm libraries (often with missing or out-of-date types), and deal with whatever other issues were caused by the newest build tool or runtime. This has gotten better over the years but it's still there.
F# and a happy medium
So I stuck with TypeScript and C# for years. I regularly experimented with different technologies like Python, Rust, Hacklang, static frameworks, etc but didn't find anything meaningfully better.
As I got more experienced with coding in my career, I found myself pushing more and more towards smaller, purer functions with immutable types and building architectures with a more functional core and imperative shell. To me this meant "functional" though I'd never really thought too hard about what that meant. It just seemed to lead to systems that were easier to understand and build and that had fewer problems.
At Instagram we did a yearly Advent of Code group and one of our directors said he really enjoyed using F# one year. He said it was fun to use and changed his way of thinking about programming even if he didn't use it for anything else.
I'd never really heard of it before but was intrigued cause it seemed to run on dotnet and didn't seem too scary to try - it was functional-ish (according to its description) but the systax seemed pretty familiar, kind of a mix of Python and C# with some TypeScript-looking types.
So I started learning F# cause I was curious and it seemed like an easy way to learn more functional paradigms that I'd been dabbling with in my projects.
It was weird. It was different. I ran into a lot of issues trying to do simple things the way I was used to - asyncs, early returns, and understanding currying to name a few.
But over time I started to see that there were other ways to accomplish the things I wanted to do. And perhaps those other ways had some advantages.
Slowly I started to understand that these oddities weren't necessarily bad, often guardrails pushing me away from pits of failure and towards pits of success. There were usually ways to still do it the way I was used to - F# is pretty flexible in that regard - but there was enough pushback to make we wonder if I should. Often the answer was no.
A few things I really liked about F#:
- Immutability by default - both at compile time and runtime (no lying!)
- Types! - Discriminated Unions, Functions, Currying. Like TypeScript but not lying and with extra goodies.
- Fast and general purpose - Everything I like from dotnet / C# including access to the full ecosystem (interop is basically just calling the C# function)
- Everything codes like LINQ - This style is called piping and it is how all code should be written. It is faster to write, simpler to understand, and generally safer with less room for error.
So I continued to build with it eventually building projects with it, moving it into my core tech stack, building a project boilerplate with it, and more recently using F# to build fullstack server-side rendered apps.
It's been a fun
journey and I really enjoy building with F#. I've never built things easier, with more correctness, and with more satisfaction about how things fit together. They get out of the way and just work.
Plus it's helped me in other areas like thinking about building safer, more scalable systems. Now when I'm faced with other languages / technologies at work it's much more clear to me where common problems are - cause they're often failure cases F# simply doesn't allow.
A recent example is getting immutable records in Python.
Next
I'm still having fun
with F# - and likely will continue to mainline it until a better language comes out for writing Simple Scalable Systems. But I'm not holding my breath as the newest generation of languages falls again into the same pitfalls we've seen for decades.
If you liked this post, you might also like:
Want more like this?
The best / easiest way to support my work is by subscribing for future updates and sharing with your network.