F# features I love

Table of contents
- Less Visual and Noise Pollution
- Dependency Order
- Expression Oriented
- Functions as First-Class Citizens
- Curried by default
- Strongly Typed and Type Inference
- Pipeline Oriented
- Structural Equality
- Pattern Matching and Active Patterns
- Computation Expressions and Comprehensions
- Fable and Bolero
- Missing features
- Conclusion

From 2018 to 2023, I worked full-time with F#, contributing to companies with revenues up to $3 billion and working on critical codebases reaching 100k lines of code. Here’s my feedback on why I enjoy this language so much.
Each F# code example is paired with its equivalent in TypeScript. The aim is not to compare both languages but to provide a reference point.
Less Visual and Noise Pollution
F# has a minimalistic philosophy. By avoiding or making optional unnecessary keywords or symbols like colons :
, semicolons ;
, braces { }
, parentheses ( )
, and virgules ,
. This offers a cleaner and more readable codebase, allowing developers to focus on the logic and the structure of the program rather than wrestling with syntax and continuously typing the same characters. F# is curly only when it’s useful.
// F#
let mean values =
let total = List.sum values
total / values.Length
// TypeScript
const mean = (values: number[]): number => {
const total = values.reduce((sum, val) => sum + val, 0);
return total / values.length;
};
Dependency Order
At first sight, this feature seems surprising and unusual, but this is actually one of my favorites.
F# doesn’t allow forward references, meaning you cannot use a type, function, or module before it has been defined. And the code must be structured such that dependencies are introduced before they are used.
Not only does this prevent circular dependencies, but it also allows the code to be read sequentially, like a book. When I read F# code, I don’t need to endlessly navigate or scroll up or down, in order to understand it. I can read it like a novel or just go to the end to grasp the goal.
As for writing code, dependency order encourages me to think modular, to compose little functions or types from other little functions or types. Now that I’ve learned F#, I even apply this pattern with other languages that do have forward references.
For more info: Cyclic dependencies are evil.
Expression Oriented
In F#, the primary focus is on expressions rather than statements.
Being expression-oriented in programming means that the language is designed in a way where nearly everything (control flow, bindings, and even code blocks) produces a value. This enables immutability, consistency, and composition.
A statement, on the other hand, is a construct that performs an action but does not produce a value. It is used for side effects, like modifying a variable or printing to the screen.
In the following code we can use a mapped type but the goal here is to compare expressions and statements with a sample.
let calculateDiscount customerType orderAmount =
// the first expression is the pattern matching control flow
// then its value is multiplied by the order amount
// and finally the entire code block result is set to the base discount
let baseDiscount =
match customerType with
| Regular -> 0.05 // 5%
| Premium -> 0.10 // 10%
* orderAmount
let additionalDiscount =
if orderAmount >= 1000.0 then 0.05 * orderAmount else 0.0
baseDiscount + additionalDiscount
function calculateDiscount(customerType: CustomerType, orderAmount: number): number {
let baseDiscountRate: number = 0;
switch (customerType) {
case CustomerType.Regular:
baseDiscountRate = 0.05; // 5%
break;
case CustomerType.Premium:
baseDiscountRate = 0.10; // 10%
break;
}
const baseDiscount = baseDiscountRate * orderAmount;
const additionalDiscount =
orderAmount >= 1000 ? 0.05 * orderAmount : 0;
return baseDiscount + additionalDiscount;
}
For more info: Expressions vs. statements.
Functions as First-Class Citizens
One of the core principles of functional programming is that functions should be treated as first-class citizens, like ordinary variables with a function type, which means they can be assigned to variables, passed as arguments, returned from other functions, and stored in data structures. Design patterns slip away to make room for just functions.
// Assign to a variable
let applyPercentageDiscount discountRate price =
price * (1m - discountRate)
let applyFlatDiscount discountAmount price =
price - discountAmount
// Pass as an argument
let calculateTotal applyDiscount prices =
prices |> List.map applyDiscount |> List.sum
// Return from another function
let welcomeDiscount =
applyFlatDiscount 10m
let createBlackFridayDiscount () =
let now = DateTime.Now.Date
if now = blackFridayOf now.Year
then applyPercentageDiscount 50m
else fun price -> price // id
// Store in a data structure
let discountStrategies =
[ welcomeDiscount
createBlackFridayDiscount () ]
type ApplyDiscount = (price: number) => number;
// Assign to a variable
const applyPercentageDiscount = (discountRate: number, price: number): number =>
price * (1 - discountRate);
const applyFlatDiscount = (discountAmount: number, price: number): number =>
price - discountAmount;
// Pass as an argument
const calculateTotal = (applyDiscount: ApplyDiscount, prices: number[]): number =>
prices.map(applyDiscount).reduce((a, b) => a + b, 0);
// Return from another function
const welcomeDiscount: ApplyDiscount = (price: number) =>
applyFlatDiscount(10, price);
const createBlackFridayDiscount = (): ApplyDiscount => {
const now = dayjs();
if (now.isSame(blackFridayOf(now.year()), "day")) {
return (price: number) => applyPercentageDiscount(0.5, price);
} else {
return (price: number) => price;
}
}
// Store in a data structure
const discountStrategies = [
welcomeDiscount,
createBlackFridayDiscount(),
];
Curried by default
In F#, functions are curried by default, meaning they inherently take arguments one at a time, returning a new function for any remaining arguments. This design offers several advantages:
Makes partial application easy and allows dependency isolation.
Improves function composition and modularity.
Enables pipeline oriented programming.
// default
let add x y = x + y
// explicitly curried
let add x = fun y -> x + y
let result = add 2 3
let addTwo = add 2
const add = (x: number) => (y: number) => x + y;
const result = add(2)(3);
const addTwo = add(2);
For more info: Currying.
Strongly Typed and Type Inference
It's occurred to me many times that I do a refactoring in a F# codebase during days, and at the end I run the tests and everything is green. It's amazing how confident we are during major refactorings. This is possible thanks to F#’s strongly typed system and its type inference feature.
While one allows us to rely on the compile-time safety, the other allows us to focus on logic and not types. The perfect balance between safety and flexibility.
The other super power I love is the domain modeling excellence. The strongly typed system lets us model complex domains, while type inference allows us to do so without making our code messy with annotations.
I highly recommend reading this excellent book: Domain Modeling Made Functional.
Pipeline Oriented
Pipeline-oriented programming in F# is a style of programming that utilizes the |>
operator to create clear and concise code by chaining function calls. This approach focuses on the flow of data through a sequence of transformations or operations, making the code more declarative and readable.
This could lead to the Railway oriented programming which is an elegant pattern for chaining together error-generating functions in a clean and composable way.
let processOrders rawOrders =
rawOrders
|> read
|> List.choose parseOrder // Parse and filter invalid orders
|> List.map calculateTotal // Calculate total price for valid orders
|> List.sortBy _.TotalPrice // Sort orders by total price
// alternative way with function composition
let processOrders =
read
>> List.choose parseOrder
>> List.map calculateTotal
>> List.sortBy _.TotalPrice
const processOrders = (rawOrders: Order[]) =>
read(rawOrders)
.map(parseOrder)
.filter((order): order is Order => order !== null)
.map(calculateTotal)
.sort((a, b) => a.TotalPrice - b.TotalPrice);
Structural Equality
Structural equality aligns well with immutability and refers to comparing two objects based on their actual content or structure, rather than their references or memory addresses. This is a key to enable consistent behavior, making the comparison predictable and reliable.
This is a valuable asset for domain modeling and tests, especially when we focus on the behavior and not its implementation. Once we have tasted this feature, it’s pretty hard to think differently.
Most F# types have built-in immutability, equality, comparison, and pretty printing. And it’s easily possible to change these type behaviors with attributes.
For more info: Equality is Hard and Out-of-the-box behavior for types.
// all the following expressions are true
[3; 2; 1] = [3; 2; 1] // list
{ Amount = 10m; Currency = EUR } = { Amount = 10m; Currency = EUR } // record
(7, "hello", true) = (7, "hello", true) // tuple
type Cart = Jack | Queen | King // union
King > Jack
As this is not aimed to be supported by JavaScript, there is no sample for it. But there are some hacky way to make a deep comparison such as Lodash library.
Pattern Matching and Active Patterns
Pattern Matching in F# is extremely valuable and it’s on the 3 of the features that I use the most.
Many patterns are available by default, such as constant, identifier, variable, list, array, tuple, record, etc. And it’s possible to enrich or to group this with Active Patterns in order to make very complex rules readable and understandable. When I go back to read mainstream languages code, and I see crazy branching with if/else/switch, it hurts my eyes and my brain.
Another benefit is the feedback on incomplete pattern matches. While with mainstream languages we could have this during the runtime by throwing an error, with F# we can rely on the compiler, linter, or the language server. One of many reasons that make refactoring more affordable.
let (|Strike|Spare|Open|Last|) rolls =
match rolls with
| 10 :: rest -> Strike rest
| r1 :: r2 :: rest when r1 + r2 = 10 -> Spare (r1, r2, rest)
| r1 :: r2 :: rest -> Open (r1, r2, rest)
| _ -> Last rolls
let calculateBowlingScore rolls =
let rec score frames total rolls =
match frames, rolls with
| 0, _ ->
total
| _, Strike nextRolls ->
let bonus = nextRolls |> List.take 2 |> List.sum
score (frames - 1) (total + 10 + bonus) nextRolls
| _, Spare (r1, r2, nextRolls) ->
let bonus = nextRolls |> List.tryHead |> Option.defaultValue 0
score (frames - 1) (total + 10 + bonus) nextRolls
| _, Open (r1, r2, nextRolls) ->
score (frames - 1) (total + r1 + r2) nextRolls
| _, Last lastRolls ->
total + List.sum lastRolls
score 10 0 rolls
type Frame =
| { kind: "Strike"; rolls: number[] }
| { kind: "Spare"; rolls: number[] }
| { kind: "Open"; rolls: number[] }
| { kind: "Last"; rolls: number[] };
function classifyRolls(rolls: number[]): Frame {
if (rolls[0] === 10) {
return { kind: "Strike", rolls: rolls.slice(1) };
} else if (rolls[0] + rolls[1] === 10) {
return { kind: "Spare", rolls: rolls.slice(2) };
} else if (rolls.length >= 2) {
return { kind: "Open", rolls: rolls.slice(2) };
} else {
return { kind: "Last", rolls };
}
}
function calculateBowlingScore(baseRolls: number[]): number {
const score = (frames: number, total: number, rolls: number[]): number => {
if (frames === 0 || rolls.length === 0) return total;
const frame = classifyRolls(rolls);
switch (frame.kind) {
case "Strike": {
const bonus = rolls.slice(1, 3).reduce((sum, roll) => sum + (roll || 0), 0);
return score(frames - 1, total + 10 + bonus, frame.rolls);
}
case "Spare": {
const bonus = rolls[2] || 0;
return score(frames - 1, total + 10 + bonus, frame.rolls);
}
case "Open": {
const frameScore = rolls[0] + rolls[1];
return score(frames - 1, total + frameScore, frame.rolls);
}
case "Last": {
return total + frame.rolls.reduce((sum, roll) => sum + roll, 0);
}
}
};
return score(10, 0, baseRolls);
}
For more info: Pattern Matching
Computation Expressions and Comprehensions
Computation Expressions (CEs) are the Swiss army knife of F#. They allow us to define and customize the behavior of a series of computations, encapsulating additional logic such as error handling, state tracking, asynchronous operations, sequence generation, or powerful DSLs.
When CEs have For
, Combine
, Yield
, and Zero
methods, they can be identified as comprehensions, like List, Array and Sequence comprehensions.
Here is an example of a custom CE of a neat behavior testing:
let spec = DeciderSpecfication (State.initial, evolve, decide)
[<Fact>]
let ``negative balance cannot be closed`` () =
spec {
Given [ Withdrawn { Amount = 50m; Date = DateTime.MinValue } ]
When (Close)
Then (ClosingError (BalanceIsNegative -50m))
}
This subject is too big to be elaborated in just one article. For more info:
Fable and Bolero
F# is a full stack language. With Fable it’s possible to transpile to JavaScript, Rust, etc, and with Bolero to compile to Wasm. Enabling F# developers to write full-stack applications in a single language. Understanding Elmish could be hard at the beginning but once we understood how it works, it’s so intuitive that you wonder why all frontend views aren’t built this way.
Missing features
I don't want F# to be the kind of language where the most empowered person in the discord chat is the category theorist. - Don Syme
I really appreciate this decision-making. F# is among the most accessible functional programming family. On the other hand, to some extent, I feel limited. That forces me to write more boilerplate code or to fall back and use a more object oriented approach.
Type classes and traits (suggestion)
Generalized algebraic data types (suggestion)
Higher kinded types (suggestion)
Conclusion
F# is more than just a programming language, it’s a gateway to a new way of thinking about software design. By prioritizing clarity, immutability, and composability without sacrificing simplicity. F# enables us to write clean, efficient, and sustainable codebases. Compared to many mainstream languages, F# reduces the cognitive load by design. It’s such a joy to know that the code you have to release will require the minimum maintenance, and if needed the minimum effort to add features or to refactor. Of course there are some bad practices to avoid.
For those accustomed to imperative or object-oriented languages, adopting F# may seem like a leap. Yet, it’s a leap worth taking. F# provides not only a practical alternative for everyday tasks but also a richer toolkit for solving complex problems with ease. Whether you’re building backend services, exploring domain modeling, or creating full-stack applications, F# is an investment in writing better and more reliable code.
If you’re curious about functional programming or looking for a language that balances elegance with pragmatism, F# is ready to surprise you. Give it a try, you may never look at code the same way again.
Subscribe to my newsletter
Read articles from Amin Khansari directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Amin Khansari
Amin Khansari
🌳🦎 Passionate about socio-technical architecture, defensive design and simple boring sustainable λ code.