Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 10 minutes
  • Examples run with .NET 10.0.401 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 10 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Cart.cs: a cart total in C# 14 C# · cart-cs/Cart.cs
// 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:

cart.fsx: the same cart in F# FSX · cart.fsx
// 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)
cart.fsx, then a mutable total at the prompt Python · cart_session.py
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:

Functions at the prompt Python · functions_session.py
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

  1. Question 1 of 2 At the prompt, let count = 4 has been entered. What does count = 5;; do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It compares count with 5 and answers val it: bool = false

    F# keeps = for comparing, so an expression such as count = 5 has a bool value. Writing a new value into a variable is <-, and only for a let mutable binding.

  2. Question 2 of 2 With let area w h = w * h, which call gives 12?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: area 3 4

    Space-separated arguments are F#'s own calling style; the tupled form with parentheses and commas belongs to functions that take a tuple, such as areaOf, and to .NET methods.

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.cs: payment kinds as strings C# · payment-cs/Payment.cs
// 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/payment.fsx: payment kinds as a union FSX · payment/payment.fsx
// 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)
The union, a new case, then a typo Python · payment_session.py
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:

Interactive · stage by stage How match picks the rule for fee (Cash 80m) Step down the column. Each rule is tried in order, and the first one whose pattern fits gives the match its value.
How match picks the rule for fee (Cash 80m), step 6 of 6. Showing fee (Cash 80m), Card amount -> (no: another case), BankTransfer -> (no: another case), Cash _ -> (fits: amount ignored), "cash, no fee". In focus: fee (Cash 80m), "cash, no fee".fee (Cash 80m)the value being matchedCard amount ->no: another caseBankTransfer ->no: another caseCash _ ->fits: amount ignored"cash, no fee"the value of the matchFS0025: Wallet (_) missing

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. Not a transfer. BankTransfer is a case with no data. It is not Cash either, so the second rule is skipped too.
  4. The rule that fits. Cash _ fits any Cash value; the underscore accepts the amount without naming it, because this rule does not need it.
  5. 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.
  6. 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.cs: a LINQ query in C# 14 C# · orders-cs/Orders.cs
// 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:

orders/orders.fsx: the same query as a pipeline FSX · orders/orders.fsx
// 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
orders.fsx, run Python · orders_session.py
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:

Interactive · stage by stage Six orders through the pipeline, one function at a time Step down the column. Each stage takes the list the stage above produced and passes on a new one.
Six orders through the pipeline, one function at a time, step 5 of 5. Showing orders, List.filter (4 orders left), List.groupBy _.City (3 groups: Pune, Leeds, Perth), List.map (3 rows: city, count, total), List.sortBy (Leeds, Perth, Pune). In focus: List.sortBy.orders6 ordersList.filter4 orders leftList.groupBy _.City3 groups: Pune, Leeds, PerthList.map3 rows: city, count, totalList.sortByLeeds, Perth, Pune

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. Six orders. The list holds six orders in three cities. Nothing has run yet; |> hands this list to the first function.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  1. Question 1 of 2 payment_new.fsx added Wallet to the Payment union and left fee unchanged. What happened when it ran?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The compiler warned FS0025 that Wallet (_) is not covered, then the script printed its three fees

    Adding a case to a union makes the compiler point at every match that does not handle it. Handle Wallet in fee, and the warning goes away.

  2. Question 2 of 2 What does orders |> List.filter keep mean?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: List.filter keep orders

    x |> f is f x, so a pipeline reads in the order the data flows: the list first, then each function that turns it into the next list.

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:

tasks_and_null.fsx: a task and an option FSX · tasks_and_null.fsx
// 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"
The script, then null at the prompt Python · tasks_session.py
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

  1. Question 1 of 2 Inside task { }, what does let! pens = fetchStock "pen" do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It waits for the task and names its result pens, like var pens = await FetchStockAsync("pen")

    In a task expression, let! awaits a task with a result, do! awaits one without, and return gives the task its value: the same shape as an async method in C#.

  2. Question 2 of 2 Why did let missing: Item = null fail with FS0043 while let text: string = null was accepted?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Item is an F# record type, which has no null value; string is a .NET type that allows null

    Types defined in F# exclude null, so null can only arrive through .NET types such as string. Option.ofObj turns such a value into Some or None at the boundary.

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:

receipt.fsx: a record, a union, a function and a class FSX · receipt.fsx
// 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
receipt.fsx, loaded into F# Interactive Python · receipt_session.py
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

  1. One question For the Receipt class, F# Interactive printed member Add: line: Line -> unit. What is Add?

    Choose one answer.

    Show the answer: One question

    Answer: A method that takes a Line and returns nothing, like a void method in C#

    F# Interactive prints a class as its constructor and members. An arrow means a method or function, and unit as the result means it is called for its effect.

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.

  1. Question 1 of 12 When is let mutable the right translation of a C# variable?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: When the value really must change in place, and no function such as List.sumBy computes it more simply

    Translate a variable assigned once into let. Keep let mutable for loops that must update a value in place, and look first for a function that computes the result directly.

  2. Question 2 of 12 You paste C# code indented with tabs into an F# file and edit it into F#. What do the tabs do?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: The compiler refuses them outside strings and comments, so indent with spaces

    Because indentation carries meaning in F#, the compiler insists on spaces. Editors set up for F# insert spaces when you press Tab.

  3. Question 3 of 12 How do you call the .NET method Math.Max with 3 and 7 from F#?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: Math.Max(3, 7)

    F# functions are curried and called with spaces; .NET methods keep their tupled shape, so calling the base class library looks the same as in C#.

  4. Question 4 of 12 Why do the F# coding conventions advise writing the parameter types of public functions, although inference would find them?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: So that a change inside a function cannot silently change its signature for every caller

    Inference is a convenience inside code; at a public boundary, written types document the contract and make an accidental change of signature a compile error in the function itself.

  5. Question 5 of 12 Which mistake does F# report when it compiles, but the C# string switch found only when it ran?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: A misspelled payment kind

    With cases instead of strings, the compiler knows every valid kind, so a misspelling is an unknown name, not a value that falls through to a default arm.

  6. Question 6 of 12 A colleague ends fee with | _ -> "no fee" to be safe. What do you lose?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: The FS0025 warning for new cases; a Wallet payment would silently get "no fee"

    A catch-all rule turns the compiler's check off. Use it only when every other value, now and in the future, truly needs the same answer.

  7. Question 7 of 12 What does List.groupBy _.City orders produce?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: A list of pairs, each a city and the list of its orders

    groupBy keeps every element, arranged into one group per key. The pipeline then reduced each group to a count and a total.

  8. Question 8 of 12 You read a large log file line by line and need only the first 10 lines that match. Do you use List or Seq functions?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: Seq, because it computes items only as they are demanded and can stop after 10

    List suits data that is already in memory and fairly small; Seq suits streams, large inputs and pipelines that stop early, with the same functions by name.

  9. Question 9 of 12 What is the F# form of name ?? "guest", when name is a string option?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: name |> Option.defaultValue "guest"

    An option says in its type that a value may be missing, and functions such as Option.defaultValue and Option.map handle both cases without a null check.

  10. Question 10 of 12 tasks_and_null.fsx reads report.Result. Why is that acceptable there but not inside another task?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: .Result blocks the thread until the task ends, which is fine at the top of a script; inside a task, let! waits without blocking

    At the edge of a program, such as a script's last line or a console app's main, waiting for a task once is normal. Everywhere else, compose tasks with let! and do!.

  11. Question 11 of 12 In receipt.fsx, what is ResizeArray<Line>?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: .NET's List<Line>, under the name F# gives it

    F# code that needs a growable .NET list writes ResizeArray, which keeps the name list free for F#'s own immutable list.

  12. Question 12 of 12 Why does receipt.fsx write text.AppendLine shop |> ignore?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: AppendLine returns the StringBuilder, and throwing that result away silently would give warning FS0020

    .NET methods that return their object for chaining meet F#'s rule that results are used. Pipe them to ignore, or chain the calls, as Render's last line does with Append and ToString.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

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

    Model answer, Pro. First step and follow-ups

Key takeaways

  • let binds an immutable value, blocks are marked by indentation with spaces, = compares, and let mutable with <- 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 switch over strings becomes a match over a union, which the compiler checks for missing cases (FS0025) and misspelled ones (FS0039).
  • A LINQ query becomes a pipeline of List or Seq functions joined by |>, with Seq lazy like LINQ to Objects and query { … } for IQueryable.
  • async and await become task { … } with let! and do!, 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.