F# Module 1 – Start here: F# for .NET developers
What F# is and where it fits in .NET
How F# relates to C#, the .NET SDK, FSharp.Core and the runtime, what functional-first changes in everyday code, and where F# earns a place in a .NET team.
What you will learn
- Explain how F# code, the F# compiler, FSharp.Core and the .NET runtime fit together
- Name the four version numbers of an F# setup and say what sets each one
- Compare the defaults of F# and C# using one rule written in both languages
- Choose where F# fits in a .NET team and name what adopting it costs
What you’ll learn
What you will learn
- Explain how F# code, the F# compiler, FSharp.Core and the .NET runtime fit together
- Name the four version numbers of an F# setup and say what sets each one
- Compare the defaults of F# and C# using one rule written in both languages
- Choose where F# fits in a .NET team and name what adopting it costs
On this page
In the 2025 Stack Overflow survey 1.3 % of respondents said they had used F#, against 27.8 % for C#. Yet a C# team that adds an F# project keeps its runtime, its libraries and its NuGet packages: the F# compiler writes the same intermediate code as the C# compiler, and the two programs in this lesson print the same five lines. What changes is the language’s defaults, and the defaults decide where F# pays for itself.
This lesson takes an F# script apart to show which piece of .NET does what, reads the four version numbers that every F# setup has, writes one pricing rule in both languages, and ends with a way to decide where F# belongs in your own code.
An F# program is a .NET program with one extra library
Here is a small script that asks the program running it two questions: which F# library and runtime it got, and which assembly each of five types comes from. At the end it hands an F# record to System.Text.Json, a .NET library written in C#:
// What runs this script, and which assembly each type it uses comes from.
open System
open System.Reflection
open System.Runtime.InteropServices
open System.Text.Json
let core = typeof<int list>.Assembly
let informational = core.GetCustomAttribute<AssemblyInformationalVersionAttribute>().InformationalVersion
printfn "FSharp.Core %s (package %s)" (string (core.GetName().Version)) (informational.Split('-', '+')[0])
printfn "Runtime %s" RuntimeInformation.FrameworkDescription
printfn "Platform %s" RuntimeInformation.RuntimeIdentifier
printfn ""
let from (t: Type) = t.Assembly.GetName().Name
for t in [ typeof<int list>; typeof<int option>; typeof<string>; typeof<Collections.Generic.List<int>>; typeof<JsonSerializer> ] do
printfn "%-16s comes from %s" t.Name (from t)
// A .NET library working on an F# type: System.Text.Json serialises an F# record.
type Order = { Item: string; Qty: int }
printfn ""
printfn "%s" (JsonSerializer.Serialize { Item = "pen"; Qty = 3 }) from session import run
# versions.fsx asks the running program which FSharp.Core and which runtime it got, and where five types come from.
run("""
dotnet fsi versions.fsx
""") Output
$ dotnet fsi versions.fsx
FSharp.Core 10.1.0.0 (package 10.1.401)
Runtime .NET 10.0.12
Platform osx-arm64
FSharpList`1 comes from FSharp.Core
FSharpOption`1 comes from FSharp.Core
String comes from System.Private.CoreLib
List`1 comes from System.Private.CoreLib
JsonSerializer comes from System.Text.Json
{"Item":"pen","Qty":3}
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 versions_session.py
Three facts are in that output. The script ran on .NET 10.0.12, the runtime a C# program on the same computer uses.
F#’s list and option types are .NET classes, FSharpList and FSharpOption, and they come from
FSharp.Core: F#'s own library: the list, option and Result types, printf, and the List, Seq and Option modules. Every F# program uses it, and a built F# program ships FSharp.Core.dll beside its own assembly., while String and List<T> come from System.Private.CoreLib, the runtime’s
own core library. And JsonSerializer, which knows nothing about F#, turned the F# record into JSON.
Treating F# as a separate platform
Wrong: F# needs a runtime, a package feed or libraries of its own. Right: an F# project runs on the same .NET runtime
and references the same NuGet packages as a C# project, which is how versions.fsx called System.Text.Json directly.
The only F#-only library is FSharp.Core.
The F# compiler, which ships inside the .NET SDK, checks the program and translates it into IL: Intermediate language: the instructions that every .NET compiler, F# and C# alike, writes into assemblies. The .NET runtime compiles them to machine code just before each method first runs., the
same intermediate language that C# compiles to, stored in an assembly. When the program starts, the .NET runtime loads
that assembly together with every library it calls, and compiles each method to machine code the first time something
calls it. Follow versions.fsx from its file to the processor:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- You write F#. F# code lives in .fs files, which a project compiles, or in .fsx scripts, which F# Interactive runs. versions.fsx is a script, run with dotnet fsi.
- The compiler checks it. The F# compiler checks every type, works out the ones you did not write, and translates the code into IL. It ships inside the .NET SDK, so installing the SDK installs F#.
- IL, as from C#. IL is the intermediate language that every .NET language compiles to. An F# assembly is an ordinary .NET assembly, so a C# project can reference it and call its public members.
- FSharp.Core comes along. F# code also uses FSharp.Core.dll, F#'s own library. F# lists and options are its types FSharpList and FSharpOption, as versions.fsx printed, and a built F# program ships a copy beside it.
- The runtime loads it all. The .NET runtime loads the assembly, FSharp.Core and the .NET libraries it calls, such as System.Text.Json, the same libraries a C# program uses.
- Machine code runs. The first time a method is called, the runtime compiles its IL for this processor and keeps the result for later calls. At this point an F# method and a C# method look exactly alike.
FSharp.Core is the one extra piece: F#’s own library, with lists, options, printf and the List, Seq and
Option modules. Its notes tell application authors to always deploy FSharp.Core as part of a
compiled application, and the build output of a new F# project holds FSharp.Core.dll beside the program’s own
assembly, as the third lesson of this module shows.
Key idea
F# compiles to the same IL as C# and runs on the same runtime with the same libraries; the one extra piece is FSharp.Core, F#’s own library, which every F# program loads.
Checkpoint
Check: The pieces of an F# program
Four version numbers, each set in a different place
An F# setup has four version numbers, and each is decided somewhere else. Two of them, and the compiler’s own version,
can be read in a terminal, here in the folder whose global.json pins the SDK this track uses:
from session import run
# The SDK this folder's global.json pins, the F# Interactive inside it, and the F# language versions its compiler knows.
run("""
dotnet --version
dotnet fsi --help | head -n 1
dotnet fsi --langversion:?
""") Output
$ dotnet --version 10.0.401 $ dotnet fsi --help | head -n 1 Microsoft (R) F# Interactive version 15.2.401.0 for F# 10.0 $ dotnet fsi --langversion:? Supported language versions: preview default latest latestmajor 4.6 4.7 5.0 6.0 7.0 8.0 9.0 10.0 (Default) 11.0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 toolchain_session.py
dotnet --version names the SDK, 10.0.401. Its F# Interactive reports itself as version 15.2.401.0 for F# 10.0: the
compiler has a version of its own, and 10.0 is the language it compiles by default, marked (Default) in the list of
language versions it accepts. The other two numbers, FSharp.Core 10.1.401 and the runtime 10.0.12, are the ones
versions.fsx printed above.
| Version | This track | Where it is set |
|---|---|---|
| The .NET SDK | 10.0.401 | The SDK you install; a global.json file picks one for a folder |
| The F# language | 10.0 | The default of the compiler inside the SDK; LangVersion in a project changes it |
| FSharp.Core | 10.1.401 | The package the SDK references for you; a library may pin a lower one |
| The .NET runtime | 10.0.12 | The project’s target framework, net10.0, and the newest 10.0 patch installed |
The first three numbers come with the SDK: its compiler sets the default language version, and the SDK brings the
FSharp.Core package that matches it. The FSharp.Core notes say that application authors do not have to reference
FSharp.Core themselves, while package authors should pin a version, as low as the library allows: FSharp.Core is
binary compatible across versions of the language, so a library built against an older FSharp.Core still runs in an
application that ships a newer one. The runtime is chosen when the program starts. Microsoft’s version-selection guide
says the host picks the latest patch installed for the target framework, so a program built for net10.0 runs on the
newest 10.0 patch the computer has. Walk through the F# releases and the .NET each one came with:
- F# 5
Packages in scripts, string interpolation scripts
F# 5 came with .NET 5. A script can reference a NuGet package with #r "nuget: …", and strings gained $"…" interpolation and nameof.
- F# 6
task expressions and xs[0]
F# 6 came with .NET 6. task { … } writes .NET tasks directly, the way C# async methods do, and indexing dropped its dot: xs[0] instead of xs.[0].
- F# 7
Static abstract interface members
F# 7 came with .NET 7. Interfaces can declare static abstract members, the feature behind generic math in .NET, and F# checks the required properties of C# types.
- F# 8
_.Name and nested copy and update
F# 8 came with .NET 8. _.Name stands for a lambda that only reads a property, and one copy-and-update expression can change a field of a nested record.
- F# 9
Nullable reference types
F# 9 came with .NET 9. With nullness checking switched on, string | null says that a value from .NET may be null and the compiler checks every use; union cases gained .Is properties such as IsEmail.
- F# 10
Scoped warnings and and! in task this track
F# 10 came with .NET 10, the long-term support release this track uses. A
- F# 11
Next, with .NET 11 preview
The next F# comes with .NET 11, which is a release candidate. The compiler of SDK 10.0.401 already lists 11.0 among the language versions it accepts, but 10.0 is its default.
The timeline has 7 events, from F# 5 to F# 11.
Which versions this track uses
Every example of this track ran on the .NET SDK 10.0.401 with the 10.0.12 runtime and F# 10. Microsoft supports only the newest servicing update of a release, and patches keep compatibility, so install the newest .NET 10 SDK for your own work; the next lesson shows how to pin one for a folder.
Checkpoint
Check: The four version numbers
Functional-first changes the defaults, not the platform
Take one pricing rule: a standard customer pays the full amount, a member gets 5 % off, or 10 % after five years, and a partner gets a negotiated rate. Here it is in C# 14, with records and a switch expression, as a current C# code base would write it:
// The same pricing rule in C# 14: records for the data, a switch expression for the rule.
Invoice[] invoices =
[
new("Asha", 1200m, new Standard()),
new("Ben", 1200m, new Member(2)),
new("Chen", 1200m, new Member(7)),
new("Dana", 1200m, new Partner(0.15m)),
];
foreach (var invoice in invoices)
{
Console.WriteLine($"{invoice.Customer,-6} {Total(invoice),8:F2}");
}
// A with expression copies the record; the old one does not change.
var renewed = invoices[1] with { Tier = new Member(5) };
Console.WriteLine($"{renewed.Customer}: {Total(invoices[1]):F2} before renewal, {Total(renewed):F2} after");
static decimal DiscountRate(Tier tier) => tier switch
{
Standard => 0m,
Member { Years: >= 5 } => 0.10m,
Member => 0.05m,
Partner p => p.Rate,
_ => throw new ArgumentOutOfRangeException(nameof(tier)),
};
static decimal Total(Invoice invoice) => invoice.Amount * (1m - DiscountRate(invoice.Tier));
abstract record Tier;
sealed record Standard : Tier;
sealed record Member(int Years) : Tier;
sealed record Partner(decimal Rate) : Tier;
record Invoice(string Customer, decimal Amount, Tier Tier); Output
Asha 1200.00 Ben 1140.00 Chen 1080.00 Dana 1020.00 Ben: 1140.00 before renewal, 1080.00 after
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run Discount.cs
And here is the same rule in F#, as a script, followed by a session that runs it and then loads it into F# Interactive, which prints the type of everything the script defines:
// One pricing rule written the F# way: a union for the customer's tier, a record for the invoice, two functions.
type Tier =
| Standard
| Member of years: int
| Partner of rate: decimal
type Invoice = { Customer: string; Amount: decimal; Tier: Tier }
let discountRate tier =
match tier with
| Standard -> 0m
| Member years when years >= 5 -> 0.10m
| Member _ -> 0.05m
| Partner rate -> rate
let total invoice =
invoice.Amount * (1m - discountRate invoice.Tier)
let invoices =
[ { Customer = "Asha"; Amount = 1200m; Tier = Standard }
{ Customer = "Ben"; Amount = 1200m; Tier = Member 2 }
{ Customer = "Chen"; Amount = 1200m; Tier = Member 7 }
{ Customer = "Dana"; Amount = 1200m; Tier = Partner 0.15m } ]
for invoice in invoices do
printfn "%-6s %8.2f" invoice.Customer (total invoice)
// Copy and update: a new invoice with another tier; the old one does not change.
let renewed = { invoices[1] with Tier = Member 5 }
printfn "%s: %.2f before renewal, %.2f after" renewed.Customer (total invoices[1]) (total renewed) from session import fsi, run
# Run the script, then load it into F# Interactive, which prints the type of everything the script defines.
run("""
dotnet fsi discount/discount.fsx
""")
fsi([
'#load "discount/discount.fsx";;',
"#quit;;",
], args=["--nologo"]) Output
$ dotnet fsi discount/discount.fsx
Asha 1200.00
Ben 1140.00
Chen 1080.00
Dana 1020.00
Ben: 1140.00 before renewal, 1080.00 after
$ dotnet fsi --nologo
> #load "discount/discount.fsx";;
[Loading discount/discount.fsx]
Asha 1200.00
Ben 1140.00
Chen 1080.00
Dana 1020.00
Ben: 1140.00 before renewal, 1080.00 after
module FSI_0002.Discount
type Tier =
| Standard
| Member of years: int
| Partner of rate: decimal
type Invoice =
{
Customer: string
Amount: decimal
Tier: Tier
}
val discountRate: tier: Tier -> decimal
val total: invoice: Invoice -> decimal
val invoices: Invoice list
val renewed: Invoice
> #quit;;
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 discount_session.py
Both programs print the same five lines, from 1200.00 for Asha down to Ben’s 1080.00 after renewal. The second half of
the session shows what the compiler worked out: discountRate takes a Tier and returns a decimal, and total
takes an Invoice, yet no parameter in the script has a type written on it. That is
type inference: The compiler working out the types of values and of a function's parameters from how they are used, so most F# code needs no type annotations and is still statically typed.: F# is as statically typed as C#, and the compiler finds most types
from how the values are used.
The two programs are about the same length. What differs is what each language does when you write nothing special:
| By default | C# 14 | F# 10 |
|---|---|---|
| A local value | can be assigned again | is immutable; let mutable opts in |
if, switch and match |
if is a statement, switch has a statement form |
everything is an expression with a value |
| Parameter types | written on every method | inferred, written where you choose |
| A fixed set of cases | records under an abstract base | 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. |
null |
allowed for reference types, with warnings | not a value of the types F# defines |
The fourth row matters most for rules like this one. In C# 14 the compiler cannot know that no other class derives from
Tier, so DiscountRate needs the _ => throw arm; add a fifth tier later and the program still compiles, then throws
when it prices an invoice with the new tier. An F# union is closed: add a case and the compiler warns about every
match that does not handle it, as the last lesson of this module shows. C# 15, a preview that comes with .NET 11,
adds union types and closed hierarchies for the same reason.
Microsoft’s guide describes F# records and unions as non-null, immutable and comparable by default, and both
languages copy a 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. instead of changing it: with made a renewed invoice for Ben and left
the first one as it was. A functional-first: A language whose defaults favour functions and immutable values: everything is an expression and values cannot change unless declared mutable, while classes and mutation stay available. language keeps classes, interfaces and
mutation, and F# uses them for .NET interop and for state that must change, but you reach for them when you need them
rather than by default.
Key idea
F# shares C#’s runtime and libraries but not its defaults: values are immutable, everything is an expression, types are inferred and unions are closed, so the compiler checks more of your rules for you.
Checkpoint
Check: F# defaults against C# defaults
Where F# earns its place in a .NET team, and what it costs
The features of the last section pay off in code that is mostly rules and data: pricing and eligibility rules, calculations, parsers, validation, and data checks and automation written as scripts. Microsoft’s F# documentation adds web development, where ASP.NET Core supports F# out of the box and community frameworks such as Giraffe and Falco build on it, and the browser, where the open-source Fable compiler turns F# into JavaScript. Code that mostly wires framework calls together gains least, because there is little logic for the types to check.
There are real costs. F#’s community is small, as the survey figure shows, so plan to train C# developers rather than
expect to hire F# ones, and everyone who reads the code before it is merged must know the language. Microsoft’s F#
strategy relies on the community for important libraries and tools, from the Ionide editor extension to the Fantomas
formatter, so part of your tooling comes from projects that Microsoft does not maintain. And the boundary with C# needs
care: the component design guidelines advise hiding union representations, avoiding curried parameters and replacing
option results with the TryGetValue pattern in libraries that C# calls, because those F# constructs look unusual
from C#.
There are four common ways to start, from a script on one laptop to a whole service. Answer the three questions about your own team and code:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Way to use F# | What the team must learn | Fit with C# code | Modelling rules and data | Finding people |
|---|---|---|---|---|
| F# scripts What it changes: checks, data jobs and automation in .fsx files | Good one person can start, and nothing new ships to production | Good a script references any .NET package with #r | Mixed unions and records help, but scripts stay small | Good nobody new is needed |
| One F# library What it changes: one project in a C# solution | Mixed everyone who changes or reads it must know F# | Mixed C# calls it like any assembly; F# options, lists and unions need a C#-friendly surface | Good closed unions and exhaustive match for the rules | Mixed a few people must know F# well |
| A service in F# What it changes: a whole application written in F# | Poor everyone on the team writes F# every day | Good ASP.NET Core and NuGet packages work as they do from C# | Good the whole domain in unions, records and functions | Poor 1.3 % of 2025 survey respondents used F#, against 27.8 % for C# |
| Stay with C# What it changes: nothing new: C# records and switch expressions | Good nothing new to learn | Good one language everywhere | Mixed C# 14 has no closed unions, so a switch over records needs a default arm | Good the large pool of C# developers |
How the answers decide
- “Does anyone on the team already write F# well?” No, nobody yet: rules out A service in F# and adds a caveat to One F# library. A team that cannot yet read F# cannot maintain a whole F# service. Start with scripts, or with one library that two people learn to write first.
- “Will C# code call the new code directly?” Yes, C# projects reference it: adds a caveat to One F# library and A service in F#. C# can call any public F# member, but F# options, lists, unions and function values look strange from C#: expose .NET types and methods at the boundary.
- “What is the new code mostly?” Rules, calculations or parsing: adds a caveat to Stay with C#. A closed union makes the compiler warn about every match that misses a case. In C# 14 a switch over records needs a default arm, so a new case fails only when it runs.
- “What is the new code mostly?” Glue between frameworks and screens: adds a caveat to One F# library and A service in F#. Code that mostly wires framework calls together gains little from unions and inference, and each F# file is one more language for the team to read.
In short: scripts cost almost nothing and teach the language; one F# library for the rules of a C# system is the usual first production step, with a C#-friendly surface; a whole service in F# needs a team that already reads F#; and plain C# stays the better choice for glue code or a team that cannot invest in a second language.
Checkpoint
Check: Where F# fits in a team
Practise: the pieces, the versions and the fit
Test yourself on the pieces of an F# program, its version numbers, the defaults of the two languages and where F# belongs. The practice starts with whichever of these ideas your answers so far show to be weakest, and each choice you make is explained on the spot:
Practice that adapts to you
Practise this lesson
9 questions about 4 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): what is F#, and how does it relate to C# and .NET?
F# is a functional-first, statically typed language for .NET. Its compiler ships inside the .NET SDK and writes the
same IL into the same kind of assemblies as the C# compiler, so F# code runs on the .NET runtime and uses the base
class library and NuGet packages exactly as C# does; the one extra library is FSharp.Core, which holds F# lists,
options and printf. A C# project can reference an F# assembly and the other way round. The differences are the
defaults: values are immutable, if and match are expressions with values, types are inferred, and discriminated
unions with exhaustive pattern matching model a closed set of cases. Classes, interfaces and mutation are still
available for interop and for state.
Two harder questions follow, about introducing F# into a C# system and about the boundary between the two languages. Note your answer first, then open each first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Your team maintains a large C# system whose pricing rules keep breaking when a new customer type is added. A colleague proposes moving the rules to F#. How would you decide, and if you go ahead, how would you introduce F# with the least risk?
-
Mid to senior
You are writing an F# library that a C# web application will call. Inside, it uses discriminated unions, options and curried functions. What should its public API look like, and why?
Key takeaways
- An F# program is a .NET program: the compiler in the .NET SDK writes the same IL as C#, the same runtime runs it, and FSharp.Core is the only extra library.
- Four version numbers describe an F# setup, the SDK, the F# language, FSharp.Core and the runtime, and each is set in
its own place, from
global.jsonto the project’s target framework. - F# and C# differ in their defaults: immutable values, expressions everywhere, inferred types and closed unions that the compiler checks for missing cases.
- F# pays off in code that is mostly rules and data, and costs training, teammates who can read it, community tooling and a careful boundary with C#; scripts or one library are the usual first steps.
Terms in this lesson
- 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.
- FSharp.Core
- F#'s own library: the list, option and Result types, printf, and the List, Seq and Option modules. Every F# program uses it, and a built F# program ships FSharp.Core.dll beside its own assembly.
- functional-first
- A language whose defaults favour functions and immutable values: everything is an expression and values cannot change unless declared mutable, while classes and mutation stay available.
- IL
- Intermediate language: the instructions that every .NET compiler, F# and C# alike, writes into assemblies. The .NET runtime compiles them to machine code just before each method first runs.
- 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.
- type inference
- The compiler working out the types of values and of a function's parameters from how they are used, so most F# code needs no type annotations and is still statically typed.
References
- What is F# (Microsoft Learn)
- F# language strategy (Microsoft Learn)
- Notes and guidance on FSharp.Core (The F# compiler team (dotnet/fsharp))
- What's new in F# 10 (Microsoft Learn)
- What's new in F# 9 (Microsoft Learn)
- What's new in F# 6 (Microsoft Learn)
- What's new in F# 5 (Microsoft Learn)
- Announcing F# 7 (Microsoft .NET Blog)
- Announcing F# 8 (Microsoft .NET Blog)
- What's new in .NET 11 (Microsoft Learn)
- What's new in C# 15 (Microsoft Learn)
- .NET releases, patches, and support (Microsoft Learn)
- Select which .NET version to use (Microsoft Learn)
- F# component design guidelines (Microsoft Learn)
- F# coding conventions (Microsoft Learn)
- F# code formatting guidelines (Microsoft Learn)
- Get started with F# in Visual Studio Code (Microsoft Learn)
- F# for web development (Microsoft Learn)
- Stack Overflow Developer Survey 2025, technology (Stack Overflow)
Report a problem with this lesson
Kept only in this browser. Your Learn progress