F# Module 1 – Start here: F# for .NET developers
From C# to F#: a side-by-side syntax map
Translate the C# you already write into idiomatic F#: var to let, methods to functions, switch to match, LINQ to pipelines, async to task, null to Option.
What you will learn
- Translate common C# constructs, var, methods, switch, LINQ, async and null checks, into idiomatic F#
- Recognise where a word-for-word translation is unidiomatic and name the F# feature that replaces it
- Read F# code that mixes functions, records, unions and .NET classes
Before you start
What you’ll learn · Before you start
What you will learn
- Translate common C# constructs, var, methods, switch, LINQ, async and null checks, into idiomatic F#
- Recognise where a word-for-word translation is unidiomatic and name the F# feature that replaces it
- Read F# code that mixes functions, records, unions and .NET classes
Before you start
On this page
Most of the C# you write has a direct F# form: var becomes let, a method becomes a function, a switch becomes a
match and a LINQ query becomes a pipeline. Three programs in this lesson are written in both languages: two print
exactly the same lines, and the third shows a mistake that only one of the two languages finds before it runs.
You will translate each construct, see where a word-for-word translation goes wrong and what replaces it, and finish by reading a short F# program that mixes records, a union, a function and a class.
let replaces var, and indentation replaces braces
Here is a shopping cart in C# 14, totalled in a loop over a dictionary of prices:
// A shopping cart in C# 14: a dictionary of prices, a running total in a loop, a method for the tax.
var prices = new Dictionary<string, decimal> { ["pen"] = 25m, ["pad"] = 60m, ["ink"] = 120m };
string[] cart = ["pen", "pen", "pad", "ink"];
var total = 0m;
foreach (var item in cart)
{
total += prices[item];
}
Console.WriteLine($"Items: {cart.Length}");
Console.WriteLine($"Total: {total}");
Console.WriteLine($"With tax at 10 %: {WithTax(0.10m, total):F2}");
static decimal WithTax(decimal rate, decimal amount) => amount * (1 + rate); Output
Items: 4 Total: 230 With tax at 10 %: 253.00
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run Cart.cs
And here is the same cart in F#, run as a script, followed by the C# loop’s habits tried at the F# prompt:
// The same cart in F#: a map of prices, a sum instead of a loop, a function instead of a method.
let prices = Map [ "pen", 25m; "pad", 60m; "ink", 120m ]
let cart = [ "pen"; "pen"; "pad"; "ink" ]
let total = cart |> List.sumBy (fun item -> prices[item])
let withTax rate amount = amount * (1m + rate)
printfn "Items: %d" cart.Length
printfn "Total: %M" total
printfn "With tax at 10 %%: %.2f" (withTax 0.10m total) from session import fsi, run
# cart.fsx run as a script; then, at the prompt, the loop written with a mutable value, and what = and <- mean.
run("""
dotnet fsi cart.fsx
""")
fsi([
'let prices = Map [ "pen", 25m; "pad", 60m; "ink", 120m ];;',
"let mutable total = 0m;;",
'for item in [ "pen"; "pen"; "pad"; "ink" ] do\n total <- total + prices[item];;',
"total = 230m;;",
"let count = 4;;",
"count = 5;;",
"count <- 5;;",
"#quit;;",
], args=["--nologo"]) Output
$ dotnet fsi cart.fsx
Items: 4
Total: 230
With tax at 10 %: 253.00
$ dotnet fsi --nologo
> let prices = Map [ "pen", 25m; "pad", 60m; "ink", 120m ];;
val prices: Map<string,decimal> =
map [("ink", 120M); ("pad", 60M); ("pen", 25M)]
> let mutable total = 0m;;
val mutable total: decimal = 0M
> for item in [ "pen"; "pen"; "pad"; "ink" ] do
- total <- total + prices[item];;
val it: unit = ()
> total = 230m;;
val it: bool = true
> let count = 4;;
val count: int = 4
> count = 5;;
val it: bool = false
> count <- 5;;
count <- 5;;
^^^^^^^^^^
stdin(8,1): error FS0027: This value is not mutable. Consider using the mutable keyword, e.g. 'let mutable count = expression'.
> #quit;;
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cart_session.py
Both programs printed the same three lines. The F# version has no braces and no semicolons: a block is marked by its
indentation alone, which F# calls the offside rule: F#'s rule that indentation marks where a block starts and ends, so code needs no braces or semicolons. The indentation must be spaces, because the compiler refuses tabs., and the formatting guidelines add
that the indentation must be spaces, because the compiler refuses tabs. let binds a name to a value that never
changes; the C# loop’s running total needs let mutable, and a new value is written with <-.
| C# 14 | F# 10 | Note |
|---|---|---|
var total = 0m; |
let total = 0m |
immutable |
total += price; |
total <- total + price |
needs let mutable total |
{ … } around a block |
indentation, with spaces | the offside rule |
a == b, a != b |
a = b, a <> b |
= compares, it never assigns |
!ready |
not ready |
&& and || are the same |
Console.WriteLine($"Total: {total}") |
printfn "Total: %M" total |
or the same $"…" string |
foreach (var x in xs) |
for x in xs do |
|
| a loop that adds up | List.sumBy |
no mutable value at all |
The last row is the idiomatic translation. The prompt shows why = needs care: count = 5 compiled, compared and
answered false, while count <- 5 stopped with FS0027, “This value is not mutable”, whose message suggests let mutable. Reach for mutable when a loop must change a value in place; to compute a total, ask a list for it.
Writing an assignment with =
Wrong: count = 5 to change count; F# reads it as a comparison and the value stays 4. Right: a new let for a new
value, or let mutable count = 4 followed by count <- 5 when the value must change in place.
Methods become functions whose types the compiler infers
A C# method lists its parameters with their types in parentheses. An F# function lists its parameters after its name, separated by spaces, and the compiler works out the types:
from session import fsi
# Functions at the prompt: curried and tupled parameters, types inferred from use, and calls to .NET methods.
fsi([
"let area w h = w * h;;",
"area 3 4;;",
"let areaOf (w, h) = w * h;;",
"areaOf (3, 4);;",
"let half x = x / 2.0;;",
"let first (a, _) = a;;",
'first ("pen", 25m);;',
"System.Math.Max(3, 7);;",
'System.String.Join(", ", [ "pen"; "pad" ]);;',
"#quit;;",
], args=["--nologo"]) Output
$ dotnet fsi --nologo
> let area w h = w * h;;
val area: w: int -> h: int -> int
> area 3 4;;
val it: int = 12
> let areaOf (w, h) = w * h;;
val areaOf: w: int * h: int -> int
> areaOf (3, 4);;
val it: int = 12
> let half x = x / 2.0;;
val half: x: float -> float
> let first (a, _) = a;;
val first: a: 'a * 'b -> 'a
> first ("pen", 25m);;
val it: string = "pen"
> System.Math.Max(3, 7);;
val it: int = 7
> System.String.Join(", ", [ "pen"; "pad" ]);;
val it: string = "pen, pad"
> #quit;;
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 functions_session.py
area takes w and then h, written int -> int -> int; they are ints because * met no other type. A
curried function: A function that takes its arguments one at a time, written with spaces, such as area 3 4. Its type, int -> int -> int, has one arrow for each argument. like this is called with spaces, area 3 4. areaOf takes one
argument, a pair, int * int, and is called with a tuple, areaOf (3, 4): that is the shape .NET methods have, so
Math.Max(3, 7) and String.Join(", ", …) are called with parentheses and commas, as in C#. half is a float
function because 2.0 is a float, and first, whose body never looks at its argument’s types, came out generic:
'a * 'b -> 'a is the F# spelling of a generic method with two type parameters.
| C# 14 | F# 10 |
|---|---|
int Area(int w, int h) => w * h; |
let area w h = w * h |
Area(3, 4) |
area 3 4 |
T First<T, U>((T, U) pair) => pair.Item1; |
let first (a, _) = a |
Math.Max(3, 7) |
Math.Max(3, 7), a .NET method keeps its tupled form |
x => x * 2 |
fun x -> x * 2 |
Inference does not make F# loosely typed: every function above has one type, fixed when it compiled. The F# coding conventions still advise writing the parameter types of public functions yourself, so that a change inside the function cannot silently change its signature for every caller.
Checkpoint
Check: let, mutable and = · Functions and inferred types
switch becomes match over a union, checked for missing cases
Older C# code often represents a choice as strings and a switch with a default arm. This one prices four payments,
and the last has a typo:
// Payment kinds as strings, as older C# code often has them: a switch expression with a default arm.
foreach (var (kind, amount) in new[] { ("card", 500m), ("bank", 500m), ("cash", 80m), ("crd", 500m) })
{
Console.WriteLine(Fee(kind, amount));
}
static string Fee(string kind, decimal amount) => kind switch
{
"card" => $"card fee {amount * 0.02m:F2}",
"bank" => "bank transfer, no fee",
"cash" => "cash, no fee",
_ => throw new ArgumentException($"unknown payment kind: {kind}"),
}; Output (exit status 134)
card fee 10.00 bank transfer, no fee cash, no fee
Printed as an error (standard error)
Unhandled exception. System.ArgumentException: unknown payment kind: crd at Program.<<Main>$>g__Fee|0_0(String kind, Decimal amount) in ./Payment.cs:line 12 at Program.<Main>$(String[] args) in ./Payment.cs:line 4
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run Payment.cs
The typo "crd" compiled, and the program printed three fees before the default arm threw ArgumentException. In F#
the kinds become the cases of a discriminated union: An F# type made of a fixed set of named cases, each with its own data, such as Standard | Member of years: int. Because the set is closed, a match over it is checked for missing cases. More in “What F# is and where it fits in .NET”, each carrying the data it
needs, and a match picks the rule:
// Payment kinds as a union: each case carries the data it needs, and match picks the rule.
type Payment =
| Card of amount: decimal
| BankTransfer
| Cash of amount: decimal
let fee payment =
match payment with
| Card amount -> sprintf "card fee %.2f" (amount * 0.02m)
| BankTransfer -> "bank transfer, no fee"
| Cash _ -> "cash, no fee"
for payment in [ Card 500m; BankTransfer; Cash 80m ] do
printfn "%s" (fee payment) from session import run
# The union and match, then the same script with a new case that fee does not handle, then a typo in a case name.
run("""
dotnet fsi payment/payment.fsx
dotnet fsi payment/payment_new.fsx
!dotnet fsi payment/payment_typo.fsx
""") Output
$ dotnet fsi payment/payment.fsx card fee 10.00 bank transfer, no fee cash, no fee $ dotnet fsi payment/payment_new.fsx payment/payment_new.fsx(9,11): warning FS0025: Incomplete pattern matches on this expression. For example, the value 'Wallet (_)' may indicate a case not covered by the pattern(s). card fee 10.00 bank transfer, no fee cash, no fee $ dotnet fsi payment/payment_typo.fsx payment/payment_typo.fsx(9,7): error FS0039: The pattern discriminator 'Crd' is not defined. [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 payment_session.py
The first script prints the same three fees. The second adds a fourth case, Wallet, without touching fee, and the
compiler warns FS0025 that the value Wallet (_) is not covered, before a single payment is priced. The third misspells
a case as Crd, and the script does not run at all: error FS0039, the pattern discriminator Crd is not defined. The
mistakes the C# version found only when it ran, F# finds when it compiles. Step through how the
match expression: F#'s pattern-matching expression: it compares a value with its rules in order, takes the first rule that fits and returns that rule's value. Over a union, missing cases are reported. picks a rule:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The value. fee is called with Cash 80m, a value of the union Payment. The match compares it with each rule in turn, from the top.
- Not a card. The first pattern, Card amount, fits only the Card case. Cash 80m is another case, so the rule is skipped and amount is never bound.
- Not a transfer. BankTransfer is a case with no data. It is not Cash either, so the second rule is skipped too.
- The rule that fits. Cash _ fits any Cash value; the underscore accepts the amount without naming it, because this rule does not need it.
- The match has a value. The arrow's expression becomes the value of the whole match, so fee returns the string cash, no fee. No rule after a fitting one is tried.
- A case no rule handles. In payment_new.fsx the union gains Wallet of provider string, and fee is unchanged. The compiler finds that no rule fits Wallet and warns FS0025, naming Wallet (_), before the script runs.
A match is an expression, so its value is the result of the rule that fits, and every rule must produce the same type.
_ fits anything: inside a case, as in Cash _, it ignores the case’s data, and as a whole rule it catches every
remaining value, which hides new cases from FS0025, so use it only where every other value really gets the same
answer. C# 15, a preview with .NET 11, brings union types with an exhaustive switch to C# for the same reason.
Key idea
A switch over strings or an open hierarchy finds missing and misspelled cases when it runs; a match over an F# union finds them when it compiles.
A LINQ query becomes a pipeline of List functions
Here is a LINQ query over six orders: keep those of 100 or more, group them by city, count and total each group, and sort by city:
// Orders of 100 or more, grouped by city, counted and totalled: a LINQ query in C# 14.
Order[] orders =
[
new("Asha", "Pune", 250m),
new("Ben", "Leeds", 80m),
new("Chen", "Pune", 120m),
new("Dana", "Leeds", 300m),
new("Eli", "Perth", 95m),
new("Femi", "Perth", 410m),
];
var byCity = orders
.Where(o => o.Amount >= 100m)
.GroupBy(o => o.City)
.Select(g => (City: g.Key, Count: g.Count(), Total: g.Sum(o => o.Amount)))
.OrderBy(row => row.City);
foreach (var (city, count, total) in byCity)
{
Console.WriteLine($"{city,-6} {count} {total,7:F2}");
}
record Order(string Customer, string City, decimal Amount); Output
Leeds 1 300.00 Perth 1 410.00 Pune 2 370.00
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run Orders.cs
The F# version is a pipeline: A chain of functions joined by the pipe operator |>, which passes the value on its left to the function on its right, so x |> f |> g means g (f x).: the list goes through one function after another, joined by
|>, which passes the value on its left to the function on its right, so orders |> List.filter keep is
List.filter keep orders:
// The same question as a pipeline of List functions.
type Order = { Customer: string; City: string; Amount: decimal }
let orders =
[ { Customer = "Asha"; City = "Pune"; Amount = 250m }
{ Customer = "Ben"; City = "Leeds"; Amount = 80m }
{ Customer = "Chen"; City = "Pune"; Amount = 120m }
{ Customer = "Dana"; City = "Leeds"; Amount = 300m }
{ Customer = "Eli"; City = "Perth"; Amount = 95m }
{ Customer = "Femi"; City = "Perth"; Amount = 410m } ]
let byCity =
orders
|> List.filter (fun o -> o.Amount >= 100m)
|> List.groupBy _.City
|> List.map (fun (city, group) -> city, List.length group, List.sumBy _.Amount group)
|> List.sortBy (fun (city, _, _) -> city)
for city, count, total in byCity do
printfn "%-6s %d %7.2f" city count total from session import run
run("""
dotnet fsi orders/orders.fsx
""") Output
$ dotnet fsi orders/orders.fsx Leeds 1 300.00 Perth 1 410.00 Pune 2 370.00
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 orders_session.py
The three lines match the C# output exactly. _.City is F# 8’s shorthand for fun o -> o.City, a lambda that only
reads a property. Follow the six orders through the pipeline:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Six orders. The list holds six orders in three cities. Nothing has run yet; |> hands this list to the first function.
- Filter. List.filter keeps the orders of 100 or more. Ben's 80 and Eli's 95 drop out, and four orders go on, Asha, Chen, Dana and Femi.
- Group. List.groupBy _.City makes one pair per city: Pune with two orders, Leeds and Perth with one each. _.City is short for fun o -> o.City.
- Map. List.map turns each pair into a row of three values, the city, how many orders it has and their total, such as Pune, 2 and 370.
- Sort. List.sortBy orders the rows by city, so the loop prints Leeds 300.00, then Perth 410.00, then Pune 370.00, the same lines as the C# query.
| LINQ | F# List module |
Note |
|---|---|---|
Where |
List.filter |
|
Select |
List.map |
|
GroupBy |
List.groupBy |
pairs of a key and its items |
OrderBy |
List.sortBy |
|
Sum, Count |
List.sumBy, List.length |
|
Any, First |
List.exists, List.head |
List.tryHead returns an option |
One difference matters for big data. The List functions build each new list completely, while the Seq module works
on any IEnumerable<T> and, as its documentation puts it for Seq.map, applies the function as elements are demanded,
which is how LINQ to Objects behaves. For a database behind IQueryable, F# has query expressions, query { … }, which
the language reference describes as F#’s support for LINQ.
Checkpoint
Check: match over a union · Pipelines instead of LINQ
async becomes task, and null becomes an option
C#’s async and await have a direct F# form, the task expression, which F# 6 added. Absent values have a different
one: instead of a null to check, F# uses an option: An F# value that is either Some value or None, used instead of null for something that may be missing. Functions such as Option.defaultValue and Option.map handle both cases., a value that is either Some x or None:
// task { } where C# writes async and await, and Option where C# checks for null.
open System
open System.Threading.Tasks
// C#: static async Task<int> FetchStockAsync(string item) { await Task.Delay(10); return ...; }
let fetchStock (item: string) =
task {
do! Task.Delay 10
return if item = "pen" then 40 else 0
}
let report =
task {
let! pens = fetchStock "pen"
let! pads = fetchStock "pad"
return $"pens {pens}, pads {pads}"
}
// A script may wait for the result; code inside other tasks uses let! instead.
printfn "%s" report.Result
// C#: string? home = Environment.GetEnvironmentVariable("SHOP_HOME"); if (home is null) ...
let shopHome = Environment.GetEnvironmentVariable "SHOP_HOME" |> Option.ofObj
match shopHome with
| Some folder -> printfn "shop home: %s" folder
| None -> printfn "SHOP_HOME is not set" from session import fsi, run
# The script, then null at the prompt: an F# record type has no null value, a .NET string does.
run("""
dotnet fsi tasks_and_null.fsx
""")
fsi([
"type Item = { Name: string };;",
"let missing: Item = null;;",
"let text: string = null;;",
"Option.ofObj text;;",
"#quit;;",
], args=["--nologo"]) Output
$ dotnet fsi tasks_and_null.fsx
pens 40, pads 0
SHOP_HOME is not set
$ dotnet fsi --nologo
> type Item = { Name: string };;
type Item =
{ Name: string }
> let missing: Item = null;;
let missing: Item = null;;
--------------------^^^^
stdin(2,21): error FS0043: The type 'Item' does not have 'null' as a proper value
> let text: string = null;;
val text: string = <null>
> Option.ofObj text;;
val it: string option = None
> #quit;;
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tasks_session.py
Inside task { … }, let! waits for a task and names its result, do! waits for a task that returns nothing, and
return gives the task its value, as await and return do in a C# async method. The script waits for report
with .Result only because it is the top of a script; code inside another task uses let! all the way.
Option.ofObj turns a .NET value that may be null into an option, and the match then cannot forget the empty case.
The prompt shows the other half: null is not a value of the record: An F# type with named fields that is immutable and compared by value by default. { r with Field = v } makes a copy with one field changed and leaves r as it was. More in “What F# is and where it fits in .NET” type Item, so
let missing: Item = null failed with FS0043, while a .NET string can still be null and Option.ofObj turned it
into None.
| C# 14 | F# 10 |
|---|---|
async Task<int> FetchAsync() { … return n; } |
task { … return n } |
var x = await FetchAsync(); |
let! x = fetch () |
await Task.Delay(10); |
do! Task.Delay 10 |
string? name |
string option, or string | null with F# 9 nullness checking |
name ?? "guest" |
name |> Option.defaultValue "guest" |
if (name is null) … |
match name with None -> … | Some n -> … |
Nullness checking, from F# 9
F# 9 added nullable reference types: with <Nullable>enable</Nullable> in the project, a type written
string | null may hold null and the compiler warns where it is used unchecked. The options lesson of this track
covers when to use it and when to convert to an option.
Checkpoint
Check: task expressions and options
Classes are still there, for state and for .NET
Real F# code mixes the pieces of this lesson. Here a record holds the data of a receipt line, a union lists the
discounts, a function computes a line’s total, and a class keeps a list of lines that grows, using .NET’s
StringBuilder to render the receipt:
// F# code as it often looks: a record, a union, a function and a class, next to a .NET class.
open System.Text
type Line = { Item: string; Qty: int; Price: decimal }
type Discount =
| NoDiscount
| Percent of decimal
let lineTotal line = decimal line.Qty * line.Price
type Receipt(shop: string) =
let lines = ResizeArray<Line>()
member _.Add(line: Line) = lines.Add line
member _.Count = lines.Count
member _.Render(discount: Discount) =
let text = StringBuilder()
text.AppendLine shop |> ignore
for line in lines do
text.AppendLine(sprintf "%-4s x%d %7.2f" line.Item line.Qty (lineTotal line)) |> ignore
let subtotal = lines |> Seq.sumBy lineTotal
let total =
match discount with
| NoDiscount -> subtotal
| Percent p -> subtotal * (1m - p / 100m)
text.Append(sprintf "Total %.2f" total).ToString()
let receipt = Receipt "Corner Shop"
receipt.Add { Item = "pen"; Qty = 2; Price = 25m }
receipt.Add { Item = "pad"; Qty = 1; Price = 60m }
printfn "%s" (receipt.Render(Percent 10m))
printfn "Lines: %d" receipt.Count from session import fsi
# Loading receipt.fsx runs it and prints the type of everything it defines, the class included.
fsi([
'#load "receipt.fsx";;',
"#quit;;",
], args=["--nologo"]) Output
$ dotnet fsi --nologo
> #load "receipt.fsx";;
[Loading receipt.fsx]
Corner Shop
pen x2 50.00
pad x1 60.00
Total 99.00
Lines: 2
module FSI_0002.Receipt
type Line =
{
Item: string
Qty: int
Price: decimal
}
type Discount =
| NoDiscount
| Percent of decimal
val lineTotal: line: Line -> decimal
type Receipt =
new: shop: string -> Receipt
member Add: line: Line -> unit
member Render: discount: Discount -> string
member Count: int
val receipt: Receipt
> #quit;;
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 receipt_session.py
Loading the script ran it and printed every definition’s type, which is a fast way to read unfamiliar F#:
| F# Interactive printed | What it is | The C# you would write |
|---|---|---|
type Line = { Item: string … } |
a record, immutable data | a record class |
type Discount = | NoDiscount | Percent of decimal |
a union, a closed set of cases | an abstract record and subclasses |
val lineTotal: line: Line -> decimal |
a function in a module | a static method |
type Receipt = new: shop: string -> Receipt |
a class with a constructor | a class with a primary constructor |
member Add: line: Line -> unit |
a method that returns nothing | a void method |
Inside the class, let lines = ResizeArray<Line>() is private state, ResizeArray being F#’s name for .NET’s
List<T>, and each member _.Name is a public member. Receipt "Corner Shop" calls the constructor without new.
The calls to StringBuilder keep their .NET shape, and each AppendLine result, which the code does not need, goes to
ignore, or the compiler would warn FS0020. This is the split the F# coding conventions describe: values are
immutable by default, and a class is where they suggest encapsulating data that must change, as lines is here. A
class is also the natural shape wherever a .NET API expects an object.
Checkpoint
Check: Reading mixed F# code
Practise: translating C# into F#
These questions come from the sections above: assignment and =, calls, match, pipelines, tasks and options, and
reading mixed code. The weakest idea goes first, and every choice explains itself when you pick it:
Practice that adapts to you
Practise this lesson
12 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): how do you translate a C# switch statement over strings into idiomatic F#?
Model the strings as the cases of a discriminated union, each case carrying the data it needs, such as Card of amount: decimal, and replace the switch with a match expression over the union. Because the union is closed, the
compiler checks the match: a case added later raises warning FS0025 at every match that misses it, and a misspelled
case name is error FS0039 instead of a value that falls through to a default arm at run time. The match is an
expression, so it returns the fee or message directly rather than assigning a variable in each branch. Parse the
incoming strings into the union once, at the edge of the program, where an unknown kind becomes an error you report,
and keep _ out of matches unless every remaining case truly needs the same answer.
Two harder questions follow, about porting C# code and about LINQ:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
You are asked to port a 300-line C# service class to F#. It has mutable fields, a switch over status strings, LINQ queries and async methods that other C# code calls. How would you approach the port, and what would the result look like?
-
Mid to senior
A C# LINQ query over a million orders is ported to an F# pipeline of List functions, and the F# version is slower and uses far more memory. Explain why, and how you would write it instead.
Key takeaways
letbinds an immutable value, blocks are marked by indentation with spaces,=compares, andlet mutablewith<-is for the rare value that must change in place.- Functions take their arguments separated by spaces and have inferred types; .NET methods keep their tupled form,
Math.Max(3, 7). - A
switchover strings becomes amatchover a union, which the compiler checks for missing cases (FS0025) and misspelled ones (FS0039). - A LINQ query becomes a pipeline of
ListorSeqfunctions joined by|>, withSeqlazy like LINQ to Objects andquery { … }forIQueryable. asyncandawaitbecometask { … }withlet!anddo!, null checks become options, and classes remain for state that changes and for .NET-shaped APIs.
Terms in this lesson
- curried function
- A function that takes its arguments one at a time, written with spaces, such as area 3 4. Its type, int -> int -> int, has one arrow for each argument.
- match expression
- F#'s pattern-matching expression: it compares a value with its rules in order, takes the first rule that fits and returns that rule's value. Over a union, missing cases are reported.
- offside rule
- F#'s rule that indentation marks where a block starts and ends, so code needs no braces or semicolons. The indentation must be spaces, because the compiler refuses tabs.
- option
- An F# value that is either Some value or None, used instead of null for something that may be missing. Functions such as Option.defaultValue and Option.map handle both cases.
- pipeline
- A chain of functions joined by the pipe operator |>, which passes the value on its left to the function on its right, so x |> f |> g means g (f x).
References
- Tour of F# (Microsoft Learn)
- F# language reference (Microsoft Learn)
- F# code formatting guidelines (Microsoft Learn)
- Verbose syntax (Microsoft Learn)
- F# coding conventions (Microsoft Learn)
- Functions (Microsoft Learn)
- Match expressions (Microsoft Learn)
- Discriminated unions (Microsoft Learn)
- List module (FSharp.Core documentation)
- Seq module (FSharp.Core documentation)
- Query expressions (Microsoft Learn)
- Announcing F# 8 (Microsoft .NET Blog)
- Task expressions (Microsoft Learn)
- Options (Microsoft Learn)
- Null values (Microsoft Learn)
- Classes (Microsoft Learn)
- What's new in C# 15 (Microsoft Learn)
Report a problem with this lesson
Kept only in this browser. Your Learn progress