Output & Running
Hello, World
Both compile to the same CLR bytecode and call into the same base class library — only the packaging differs.
Option Strict On
Imports System
Module HelloWorld
Sub Main()
Console.WriteLine("Hello, World!")
End Sub
End Moduleprintfn "Hello, World!"F# runs top-level code the way a script does: no
Module, no Sub Main, no End. Console.WriteLine "Hello, World!" works too, because the whole base class library is right there. printfn is preferred because its format string is type-checked at compile time — the next row shows what that buys you.Formatted output
Two output styles: the interpolated string you already know, and a typed format string with no counterpart in .NET.
Option Strict On
Imports System
Module FormatDemo
Sub Main()
Dim name As String = "Ada"
Dim score As Integer = 42
Dim ratio As Double = 0.8756
Console.WriteLine($"Hello, {name}! Score: {score}")
Console.WriteLine($"Rounded: {ratio:F2}")
Console.WriteLine(String.Format("{0} scored {1}", name, score))
End Sub
End Modulelet name = "Ada"
let score = 42
let ratio = 0.8756
printfn $"Hello, {name}! Score: {score}"
printfn "Rounded: %.2f" ratio
printfn "%s scored %d" name score
printfn "%A" (name, score)$"..." works exactly as in Visual Basic. The other form uses typed placeholders: %s for a string, %d for an integer, %.2f for a rounded float, %b for a boolean, %A for "show me the structure". Passing the wrong type is a compile error, which String.Format("{0}") can never be. %A is the one you will reach for constantly — it prints records, lists and tuples readably with no ToString to write.Where the program starts
Top-level code is the entry point — until you build an application, when one function is marked as it.
Option Strict On
Imports System
Module Program
Sub Main(args As String())
Console.WriteLine($"got {args.Length} argument(s)")
End Sub
End Modulelet describe (args: string array) =
printfn $"got {args.Length} argument(s)"
describe [| "one"; "two" |]
// A compiled application marks its entry point instead:
//
// [<EntryPoint>]
// let main args =
// printfn "%d" args.Length
// 0A script or a single-file program simply runs its top-level bindings in order. A compiled executable marks one function with the
[<EntryPoint>] attribute; it must be the last declaration in the last file, take a string array, and return an int exit code. Note that F# compiles files strictly in order and a name must be defined before it is used — there is no forward reference and no equivalent of a Module whose members are all visible at once.Syntax Fundamentals
Indentation is the syntax
Every
End disappears, and the indentation you already write becomes load-bearing.Option Strict On
Imports System
Module IndentDemo
Sub Main()
Dim temperature As Integer = 30
If temperature > 25 Then
Console.WriteLine("Warm")
If temperature > 35 Then
Console.WriteLine("Very warm")
End If
Else
Console.WriteLine("Cool")
End If
End Sub
End Modulelet temperature = 30
if temperature > 25 then
printfn "Warm"
if temperature > 35 then
printfn "Very warm"
else
printfn "Cool"A block is delimited by indentation, as in Python — there is no
End If, no End Sub, no braces. Then survives, in lower case. Tabs are rejected outright, not merely discouraged. The upside for a Visual Basic reader is that the shape of the code barely changes: delete the End lines, lower-case the keywords, and the indentation was already right.Comments
Three forms, and the documentation one keeps the XML tags you already write.
Option Strict On
Imports System
Module CommentDemo
''' <summary>Doubles a number.</summary>
Function Twice(value As Integer) As Integer
' A line comment
Return value * 2
End Function
Sub Main()
Console.WriteLine(Twice(21))
End Sub
End Module/// <summary>Doubles a number.</summary>
let twice value =
// A line comment
(* and a block comment *)
value * 2
printfn "%d" (twice 21)' becomes //, and there is a block form (* ... *) which Visual Basic has never had. The documentation comment ''' becomes /// and takes the same XML tags — <summary>, <param>, <returns> — because both compilers emit the same documentation file. This is the first of many places where staying on .NET means you carry your tooling with you.Case sensitivity
The usual adjustment, with F#'s own naming convention layered on top.
Option Strict On
Imports System
Module CaseDemo
Sub Main()
Dim customerName As String = "Grace"
Console.WriteLine(customerName)
Console.WriteLine(CustomerName)
End Sub
End Modulelet customerName = "Grace"
let CustomerName = "Hopper"
printfn "%s" customerName
printfn "%s" CustomerNameIdentifiers are case-sensitive, so
customerName and CustomerName are two bindings. F# convention is camelCase for let-bound values and functions and PascalCase for types, modules and public members — which differs from .NET's usual PascalCase-everything precisely because most F# values are local rather than public API.Bindings & Types
Dim becomes let — and it does not vary
The default flips: a name is immutable unless you say otherwise, and the assignment operator changes to make the difference visible.
Option Strict On
Imports System
Module BindingDemo
Sub Main()
Dim count As Integer = 10
count = 20
Dim label As String = "widget"
Console.WriteLine($"{count} {label}")
End Sub
End Modulelet count = 10
// count <- 20 // error: count is not mutable
let mutable total = 10
total <- 20
let label = "widget"
printfn "%d %d %s" count total labelDim becomes let, and a let binding cannot be reassigned — let means "this name refers to this value", not "this box holds this value". When you genuinely need a variable, let mutable declares one and <- assigns to it; the different operator means you can always see mutation on the page. Most F# code has very little of it, which is the point: a value that cannot change cannot be changed by accident from somewhere else.Type inference everywhere
The types are still there and still checked — you simply stop writing most of them.
Option Strict On
Imports System
Module InferenceDemo
Function Add(left As Integer, right As Integer) As Integer
Return left + right
End Function
Sub Main()
Dim total As Integer = Add(2, 3)
Console.WriteLine(total)
End Sub
End Modulelet add left right = left + right
let total = add 2 3
printfn "%d" total
let describe (value: string) : int = value.Length
printfn "%d" (describe "hello")F# infers types across whole functions, not just single assignments, so
add is typed int -> int -> int with nothing declared. This is stronger than Option Infer, which only infers a variable from its initializer. Annotations are still available and use the same right-to-left order as As Integer: (value: string) : int. You need them where inference cannot decide, most often to pick which type a member call belongs to.Numbers and explicit conversion
The types are the same .NET types with the same suffixes — what changes is that nothing converts on its own.
Option Strict On
Imports System
Module NumberDemo
Sub Main()
Dim whole As Integer = 7
Dim big As Long = 9L
Dim precise As Double = 6.7
Dim exact As Decimal = 8.9D
' Option Strict On still WIDENS silently
Dim widened As Double = whole
Console.WriteLine($"{whole} {big} {precise} {exact} {widened}")
End Sub
End Modulelet whole = 7
let big = 9L
let precise = 6.7
let exact = 8.9m
// No implicit conversion at all — not even widening
let widened = float whole
let asInt = int precise
printfn "%d %d %f %M %f %d" whole big precise exact widened asIntInteger→int, Long→int64 (L suffix), Double→float, Single→float32, Decimal→decimal (m suffix, lower case). The strictness goes beyond Option Strict On: F# performs no implicit conversion whatsoever, including widening, so adding an int to a float is a compile error until you write float whole. Conversion functions are named after the target type — int, float, string, decimal.Nothing becomes option
Instead of a value that might be
Nothing, the possibility of absence is written into the type.Option Strict On
Imports System
Module NothingDemo
Function FindName(id As Integer) As String
If id = 1 Then Return "Ada"
Return Nothing
End Function
Sub Main()
Dim found As String = FindName(1)
If found IsNot Nothing Then Console.WriteLine(found)
Dim missing As String = FindName(2)
Console.WriteLine(If(missing, "(not found)"))
End Sub
End Modulelet findName id =
if id = 1 then Some "Ada" else None
match findName 1 with
| Some name -> printfn "%s" name
| None -> printfn "(not found)"
printfn "%s" (findName 2 |> Option.defaultValue "(not found)")
printfn "%b" (findName 1 |> Option.isSome)An
option is either Some value or None, and the compiler will not let you use the value without handling both. That is the difference from Nothing: a String that might be Nothing looks exactly like one that cannot be, so nothing reminds you to check. Option.defaultValue is the two-argument If(); Option.map transforms the value if there is one. F# code that stays inside F# rarely sees a null at all — nulls arrive from C# libraries at the boundary.Operators & Piping
Comparison and assignment
A rare case where F# is closer to Visual Basic than C# is:
= compares and <> means not-equal.Option Strict On
Imports System
Module EqualityDemo
Sub Main()
Dim left As Integer = 5
Dim right As Integer = 5
If left = right Then Console.WriteLine("equal")
If left <> 6 Then Console.WriteLine("not six")
Dim total As Integer = left
total = total + 1
Console.WriteLine(total)
End Sub
End Modulelet left = 5
let right = 5
if left = right then printfn "equal"
if left <> 6 then printfn "not six"
let mutable total = left
total <- total + 1
printfn "%d" total= is equality, not assignment, and <> is inequality — both exactly as you write them today. F# can afford this because assignment to a mutable binding uses a different operator, <-. Equality is structural by default: two records or lists with the same contents are equal, with no Equals to override. There is no truthiness — a condition must be a bool.The pipeline operator
The operator that makes F# read the way a LINQ chain reads, without needing the methods to hang off the object.
Option Strict On
Imports System
Imports System.Linq
Module PipelineDemo
Sub Main()
Dim numbers() As Integer = {5, 3, 9, 1, 7, 2}
Dim total = numbers.
Where(Function(number) number > 2).
Select(Function(number) number * 10).
Sum()
Console.WriteLine(total)
End Sub
End Modulelet numbers = [ 5; 3; 9; 1; 7; 2 ]
let total =
numbers
|> List.filter (fun number -> number > 2)
|> List.map (fun number -> number * 10)
|> List.sum
printfn "%d" total
// x |> f is just f x
printfn "%d" (numbers |> List.length)x |> f means f x — it feeds the value on the left into the function on the right as its last argument. That is all it is, and it is why every List function takes the collection last. The result reads top to bottom in the order the work happens, exactly like a method chain, but works with any function at all rather than only methods defined on the type. Chained backwards it is <|, and >> composes two functions into one without mentioning a value.Arithmetic
The same trap C# sets:
/ means integer division when both operands are integers.Option Strict On
Imports System
Module ArithmeticDemo
Sub Main()
Dim quotient As Integer = 17 \ 5
Dim exact As Double = 17 / 5
Dim remainder As Integer = 17 Mod 5
Dim squared As Double = 7 ^ 2
Console.WriteLine($"{quotient} {exact} {remainder} {squared}")
End Sub
End Modulelet quotient = 17 / 5
let exact = 17.0 / 5.0
let remainder = 17 % 5
let squared = 7.0 ** 2.0
printfn "%d %f %d %f" quotient exact remainder squaredThere is no
\: 17 / 5 between two ints truncates to 3, so a Visual Basic / copied across silently changes meaning. Because F# does no implicit conversion, the fix is explicit on both sides — 17.0 / 5.0, not 17 / 5.0, which will not compile. Mod becomes %, and ^ becomes ** and works only on floats. In F# ^ concatenates strings, so writing it out of habit is a type error rather than a wrong answer.Strings
String operations
These are the identical
System.String methods — the only change is where the parentheses go.Option Strict On
Imports System
Module StringDemo
Sub Main()
Dim text As String = " Visual Basic "
Console.WriteLine($"[{text.Trim()}]")
Console.WriteLine(text.Trim().ToUpper())
Console.WriteLine(text.Contains("Basic"))
Console.WriteLine(text.Trim().Replace(" ", "-"))
Console.WriteLine(text.Trim().Length)
End Sub
End Modulelet text = " Visual Basic "
printfn "[%s]" (text.Trim())
printfn "%s" (text.Trim().ToUpper())
printfn "%b" (text.Contains "Basic")
printfn "%s" (text.Trim().Replace(" ", "-"))
printfn "%d" (text.Trim().Length)
printfn "%s" (text |> String.filter (fun c -> c <> ' '))Because F# is a .NET language,
Trim, ToUpper, Contains, Replace and Length are the same members you already call, spelled the same way. What is new is the String module — String.filter, String.map, String.concat — which treats a string as a sequence of characters and composes with the pipeline. Method calls need their arguments parenthesized when they are part of a larger expression, which is the main visual adjustment.Quoted and multi-line strings
The backslash escapes here, so a Windows path needs one of the two literal forms C# also offers.
Option Strict On
Imports System
Module QuotingDemo
Sub Main()
Dim quoted As String = "She said ""hello""."
Dim path As String = "C:\reports\summary.txt"
Dim block As String = "line one" & Environment.NewLine & "line two"
Console.WriteLine(quoted)
Console.WriteLine(path)
Console.WriteLine(block)
End Sub
End Modulelet quoted = "She said \"hello\"."
let path = @"C:\reports\summary.txt"
let block = """line one
line two"""
printfn "%s" quoted
printfn "%s" path
printfn "%s" blockUnlike Visual Basic, F# treats
\ as an escape, so "C:\reports" means something else. @"..." is the verbatim form, identical to C#'s. Triple quotes give a literal that spans lines and honors nothing at all — not even a doubled quote — which makes it the right home for JSON and regular expressions. String concatenation is +, and ^ also works but is rarely used.Lists, Arrays & Maps
Lists — immutable, and separated by semicolons
Note the semicolons, and note that nothing was added to anything.
Option Strict On
Imports System
Imports System.Collections.Generic
Module ListDemo
Sub Main()
Dim fruits As New List(Of String) From {"apple", "banana"}
fruits.Add("cherry")
Console.WriteLine(fruits.Count)
Console.WriteLine(fruits(0))
Console.WriteLine(String.Join(", ", fruits))
End Sub
End Modulelet fruits = [ "apple"; "banana" ]
let more = "cherry" :: fruits // a NEW list
let appended = fruits @ [ "date" ]
printfn "%d" (List.length fruits)
printfn "%s" (List.head fruits)
printfn "%s" (String.concat ", " more)
printfn "%A" appendedAn F#
list is an immutable singly-linked list, written with semicolons because the comma builds a tuple. There is no Add: :: ("cons") produces a new list with an item on the front, and @ concatenates. Because the tail is shared, prepending is cheap and appending is not — which is why F# code prepends and reverses rather than appending in a loop. It is not List(Of T); that type is ResizeArray in F#, and it is still there when you want it.LINQ becomes the List module
Every LINQ operator has a counterpart, as a function in a module rather than a method on the type.
Option Strict On
Imports System
Imports System.Linq
Module LinqDemo
Sub Main()
Dim numbers() As Integer = {5, 3, 9, 1, 7, 2}
Console.WriteLine(String.Join(", ", numbers.Where(Function(n) n > 2)))
Console.WriteLine(numbers.Sum())
Console.WriteLine(numbers.Any(Function(n) n > 8))
Console.WriteLine(numbers.OrderBy(Function(n) n).First())
Console.WriteLine(numbers.Aggregate(Function(a, b) a + b))
End Sub
End Modulelet numbers = [ 5; 3; 9; 1; 7; 2 ]
printfn "%A" (numbers |> List.filter (fun n -> n > 2))
printfn "%d" (numbers |> List.sum)
printfn "%b" (numbers |> List.exists (fun n -> n > 8))
printfn "%d" (numbers |> List.min)
printfn "%d" (numbers |> List.fold (+) 0)
printfn "%A" (numbers |> List.groupBy (fun n -> n % 2 = 0))
printfn "%A" (numbers |> List.sortBy id |> List.take 3)Where→List.filter, Select→List.map, Any→List.exists, All→List.forall, FirstOrDefault→List.tryFind (which returns an option), OrderBy→List.sortBy, Aggregate→List.fold, GroupBy→List.groupBy. The same names exist under Array and Seq; Seq is IEnumerable<T> and is the lazy one, so it is what LINQ itself most resembles. All the LINQ extension methods still work too, since these are .NET collections.Arrays and maps
The array is mutable and familiar; the map is immutable and adds by producing a new one.
Option Strict On
Imports System
Imports System.Collections.Generic
Module MapDemo
Sub Main()
Dim scores(2) As Integer
scores(0) = 10
Dim ages As New Dictionary(Of String, Integer) From {
{"Ada", 36},
{"Grace", 45}
}
ages("Alan") = 41
Console.WriteLine(scores.Length)
Console.WriteLine(ages("Ada"))
Console.WriteLine(ages.ContainsKey("Nobody"))
End Sub
End Modulelet scores = Array.zeroCreate<int> 3
scores.[0] <- 10
let ages = Map [ "Ada", 36; "Grace", 45 ]
let withAlan = ages |> Map.add "Alan" 41
printfn "%d" scores.Length
printfn "%d" (Map.find "Ada" ages)
printfn "%A" (Map.tryFind "Nobody" ages)
printfn "%d" (Map.count withAlan)An F#
array is a .NET array — fixed length, mutable, indexed with .[i] and assigned with <-. Array.zeroCreate 3 takes a count, not an upper bound, so Dim scores(2) becomes zeroCreate 3. A Map is the immutable dictionary: Map.add returns a new map rather than changing the old one. Map.tryFind returns an option, which is TryGetValue without the output parameter. The mutable Dictionary(Of K, V) is still available under its own name.Control Flow
if produces a value
The
If ladder moves to the right of the =, so the variable is assigned once and never left empty.Option Strict On
Imports System
Module IfDemo
Sub Main()
Dim score As Integer = 72
Dim grade As String
If score >= 90 Then
grade = "A"
ElseIf score >= 70 Then
grade = "B"
Else
grade = "F"
End If
Console.WriteLine(grade)
End Sub
End Modulelet score = 72
let grade =
if score >= 90 then "A"
elif score >= 70 then "B"
else "F"
printfn "%s" gradeElseIf becomes elif, and the whole if is an expression whose value is the chosen branch. Every branch must therefore have the same type, and an if with no else must be of type unit — meaning it does something rather than producing something. That rule is what stops the "declared but not yet assigned" state the anchor column passes through.Select Case becomes match
The direct counterpart, and it is an expression, so the
Returns disappear.Option Strict On
Imports System
Module SelectDemo
Function Describe(code As Integer) As String
Select Case code
Case 1
Return "one"
Case 2, 3
Return "two or three"
Case 4 To 6
Return "four to six"
Case Is > 100
Return "large"
Case Else
Return "something else"
End Select
End Function
Sub Main()
Console.WriteLine(Describe(1))
Console.WriteLine(Describe(3))
Console.WriteLine(Describe(5))
Console.WriteLine(Describe(200))
End Sub
End Modulelet describe code =
match code with
| 1 -> "one"
| 2 | 3 -> "two or three"
| n when n >= 4 && n <= 6 -> "four to six"
| n when n > 100 -> "large"
| _ -> "something else"
printfn "%s" (describe 1)
printfn "%s" (describe 3)
printfn "%s" (describe 5)
printfn "%s" (describe 200)Select Case x→match x with, each Case becomes a | arm, Case Else becomes | _. Case 2, 3 becomes | 2 | 3. A range or a comparison becomes a guard: | n when n > 100 binds the value and adds a condition. The compiler checks that the arms are exhaustive and warns when one can never be reached — neither of which Select Case can do, because a Visual Basic Integer has no closed set of values.Loops still exist
F# is not loop-averse — the counted loop keeps its inclusive range, which is closer to Visual Basic than C# manages.
Option Strict On
Imports System
Imports System.Collections.Generic
Module LoopDemo
Sub Main()
For index As Integer = 1 To 5
Console.Write(index & " ")
Next
Console.WriteLine()
Dim words As New List(Of String) From {"alpha", "beta"}
For Each word As String In words
Console.WriteLine(word.ToUpper())
Next
Dim remaining As Integer = 3
While remaining > 0
remaining -= 1
End While
Console.WriteLine(remaining)
End Sub
End Modulefor index in 1 .. 5 do
printf "%d " index
printfn ""
for index in 10 .. -2 .. 0 do
printf "%d " index
printfn ""
let words = [ "alpha"; "beta" ]
for word in words do
printfn "%s" (word.ToUpper())
let mutable remaining = 3
while remaining > 0 do
remaining <- remaining - 1
printfn "%d" remainingFor index = 1 To 5 becomes for index in 1 .. 5 do, and the range is inclusive at both ends, so there is no off-by-one to convert. Step -2 becomes the middle element of 10 .. -2 .. 0. For Each becomes the same for ... in. While ... End While becomes while ... do. There is no Do ... Loop Until and no Exit For or Continue For at all — a loop that needs to stop early is written as a recursive function or a Seq pipeline instead.Functions
Sub and Function both become let
The same keyword that binds a value binds a function, and the arguments are separated by spaces rather than commas.
Option Strict On
Imports System
Module FunctionDemo
Sub Announce(message As String)
Console.WriteLine($"** {message} **")
End Sub
Function Add(left As Integer, right As Integer) As Integer
Return left + right
End Function
Sub Main()
Announce("starting")
Console.WriteLine(Add(2, 3))
End Sub
End Modulelet announce message =
printfn "** %s **" message
let add left right =
left + right
announce "starting"
printfn "%d" (add 2 3)There is no
Sub/Function split: a function that returns nothing returns unit, written (), which is F#'s void. There is no Return — the last expression is the result. Arguments are applied by juxtaposition: add 2 3, not add(2, 3). Writing the parenthesized form still compiles but means something subtly different — it passes a tuple — which the currying row explains.Every function takes one argument
Supplying some of the arguments and keeping the rest for later needs no lambda and no wrapper.
Option Strict On
Imports System
Module PartialDemo
Function Multiply(factor As Integer, value As Integer) As Integer
Return factor * value
End Function
Sub Main()
' A lambda is needed to fix the first argument
Dim double_ As Func(Of Integer, Integer) = Function(value) Multiply(2, value)
Console.WriteLine(double_(21))
Console.WriteLine(Multiply(3, 5))
End Sub
End Modulelet multiply factor value = factor * value
let double = multiply 2
let triple = multiply 3
printfn "%d" (double 21)
printfn "%d" (multiply 3 5)
printfn "%A" ([ 1; 2; 3 ] |> List.map double)A two-argument F# function is really a function that takes one argument and returns a function taking the next — that is currying, and its type is written
int -> int -> int for exactly that reason. So multiply 2 is a perfectly good value: a function awaiting one more argument. This is why the pipeline works so smoothly, and why List.map double reads as it does. Visual Basic has no counterpart; the nearest thing is writing the lambda by hand every time.Lambdas and composition
The lambda keyword changes and the arrow appears; composition is the new capability.
Option Strict On
Imports System
Imports System.Linq
Module LambdaDemo
Sub Main()
Dim numbers() As Integer = {1, 2, 3, 4}
Dim result = numbers.
Select(Function(value) value * 2).
Where(Function(value) value > 4)
Console.WriteLine(String.Join(", ", result))
End Sub
End Modulelet numbers = [ 1; 2; 3; 4 ]
let result =
numbers
|> List.map (fun value -> value * 2)
|> List.filter (fun value -> value > 4)
printfn "%A" result
let doubleThenShow = (fun v -> v * 2) >> string
printfn "%s" (doubleThenShow 21)Function(value) value * 2 becomes fun value -> value * 2. There is no separate Sub() form and no Func/Action type to declare — a function type is written int -> int. The addition is composition: f >> g builds a new function that applies f then g, without naming the value at all. Compare it with |>, which pushes a value through; >> joins two functions into one.Recursion needs a keyword
A function cannot call itself unless it says so — and recursion does the job the missing
Exit For used to.Option Strict On
Imports System
Module RecursionDemo
Function Factorial(value As Integer) As Long
If value <= 1 Then Return 1L
Return value * Factorial(value - 1)
End Function
Sub Main()
Console.WriteLine(Factorial(10))
End Sub
End Modulelet rec factorial value =
if value <= 1 then 1L
else int64 value * factorial (value - 1)
printfn "%d" (factorial 10)
let sumTo target =
let rec loop current total =
if current > target then total
else loop (current + 1) (total + current)
loop 1 0
printfn "%d" (sumTo 100)F# compiles top to bottom, so a name is not in scope inside its own definition unless marked
let rec. The second example shows the idiom that replaces a mutable accumulator loop: an inner loop function carrying the state as arguments. Because the recursive call is the last thing it does, the compiler turns it into an actual loop — tail-call optimization — so it does not grow the stack. Visual Basic has no such guarantee, which is why deep recursion is avoided there.Records & Unions
Records
One line declares the fields, the constructor, structural equality, hashing and a readable display.
Option Strict On
Imports System
Public Class Person
Public ReadOnly Property Name As String
Public ReadOnly Property Age As Integer
Public Sub New(name As String, age As Integer)
Me.Name = name
Me.Age = age
End Sub
Public Overrides Function Equals(other As Object) As Boolean
Dim candidate = TryCast(other, Person)
Return candidate IsNot Nothing AndAlso
candidate.Name = Name AndAlso candidate.Age = Age
End Function
Public Overrides Function GetHashCode() As Integer
Return HashCode.Combine(Name, Age)
End Function
End Class
Module RecordDemo
Sub Main()
Dim person As New Person("Ada", 36)
Dim same As New Person("Ada", 36)
Console.WriteLine(person.Name)
Console.WriteLine(person.Equals(same))
End Sub
End Moduletype Person = { Name: string; Age: int }
let person = { Name = "Ada"; Age = 36 }
let same = { Name = "Ada"; Age = 36 }
let older = { person with Age = 37 }
printfn "%s" person.Name
printfn "%b" (person = same)
printfn "%A" olderA record gets value-based equality and a useful
%A rendering for free, which is the whole anchor column written for you. It is immutable, so with produces a copy with named fields changed rather than mutating. Records are ordinary .NET classes underneath, so C# and Visual Basic can consume them. Note that field names begin with a capital and that the type is inferred at the construction site — no : Person annotation needed.Discriminated unions
The single feature most likely to change how you model a problem, and .NET has nothing else like it.
Option Strict On
Imports System
Public MustInherit Class Shape
End Class
Public Class Circle
Inherits Shape
Public ReadOnly Radius As Double
Public Sub New(radius As Double)
Me.Radius = radius
End Sub
End Class
Public Class Rectangle
Inherits Shape
Public ReadOnly Width As Double
Public ReadOnly Height As Double
Public Sub New(width As Double, height As Double)
Me.Width = width
Me.Height = height
End Sub
End Class
Module UnionDemo
Function Area(shape As Shape) As Double
If TypeOf shape Is Circle Then Return Math.PI * DirectCast(shape, Circle).Radius ^ 2
If TypeOf shape Is Rectangle Then
Dim rect = DirectCast(shape, Rectangle)
Return rect.Width * rect.Height
End If
Throw New ArgumentException("unknown shape")
End Function
Sub Main()
Console.WriteLine(Area(New Circle(2.0)).ToString("F2"))
Console.WriteLine(Area(New Rectangle(3.0, 4.0)).ToString("F2"))
End Sub
End Moduletype Shape =
| Circle of radius: float
| Rectangle of width: float * height: float
let area shape =
match shape with
| Circle radius -> System.Math.PI * radius * radius
| Rectangle (width, height) -> width * height
printfn "%.2f" (area (Circle 2.0))
printfn "%.2f" (area (Rectangle (3.0, 4.0)))A discriminated union says "a Shape is a Circle or a Rectangle, and nothing else". The whole class hierarchy in the anchor column collapses to three lines, the casts disappear because
match binds the fields directly, and — the important part — the compiler warns if a case is unhandled. Add a Triangle and every incomplete match in the program lights up, which is the opposite of the anchor column, where the missing branch surfaces as a runtime exception.Tuples
A comma builds a tuple — which is exactly why a list uses semicolons.
Option Strict On
Imports System
Module TupleDemo
Function MinimumAndMaximum(values() As Integer) As (Smallest As Integer, Largest As Integer)
Dim smallest As Integer = values(0)
Dim largest As Integer = values(0)
For Each value As Integer In values
If value < smallest Then smallest = value
If value > largest Then largest = value
Next
Return (smallest, largest)
End Function
Sub Main()
Dim result = MinimumAndMaximum(New Integer() {4, 9, 1, 7})
Console.WriteLine($"{result.Smallest}..{result.Largest}")
End Sub
End Modulelet minimumAndMaximum values =
(List.min values, List.max values)
let (low, high) = minimumAndMaximum [ 4; 9; 1; 7 ]
printfn "%d..%d" low high
let pairs = List.zip [ 1; 2 ] [ "a"; "b" ]
printfn "%A" pairs
printfn "%A" (pairs |> List.map fst)Tuples need no declaration:
(a, b) has type int * string, and the * is read as "and". Destructuring on the left of a let pulls the parts out, and fst and snd reach into a pair. F# tuples have no field names, unlike Visual Basic's, so when the parts deserve names the answer is a record. Since C# 7 both languages share the same underlying ValueTuple, so tuples cross the language boundary intact.Units of measure
A compile-time check that a
Double is the right kind of double — with no runtime cost at all.Option Strict On
Imports System
Module UnitDemo
Sub Main()
' Nothing stops these being added together
Dim distanceMetres As Double = 100.0
Dim timeSeconds As Double = 9.58
Dim nonsense As Double = distanceMetres + timeSeconds
Console.WriteLine(nonsense)
Console.WriteLine((distanceMetres / timeSeconds).ToString("F2"))
End Sub
End Module[<Measure>] type m
[<Measure>] type s
let distance = 100.0<m>
let time = 9.58<s>
// let nonsense = distance + time // compile error
let speed = distance / time
printfn "%.2f" (float speed)
printfn "%A" speedAttaching a unit of measure to a numeric type makes metres and seconds different types, so adding them is a compile error while dividing them correctly produces
float<m/s>. The units are erased at compile time, so the arithmetic is exactly as fast as plain float. There is nothing like this in Visual Basic or C#, and it is the kind of mistake — mixing pounds with kilograms, cents with dollars — that a business application actually makes.Classes & Interfaces
Classes are still here
F# is a .NET language, so classes, properties and inheritance all exist — you simply reach for them less often.
Option Strict On
Imports System
Public Class Counter
Private _total As Integer = 0
Public Sub Increment()
_total += 1
End Sub
Public ReadOnly Property Total As Integer
Get
Return _total
End Get
End Property
End Class
Module ClassDemo
Sub Main()
Dim counter As New Counter()
counter.Increment()
counter.Increment()
Console.WriteLine(counter.Total)
End Sub
End Moduletype Counter() =
let mutable total = 0
member _.Increment() =
total <- total + 1
member _.Total = total
let counter = Counter()
counter.Increment()
counter.Increment()
printfn "%d" counter.TotalThe parentheses after the type name are the primary constructor:
type Counter() = takes no arguments, type Person(name: string) = takes one, and the body between it and the first member is the constructor. A let inside a class is a private field. member declares a public member; the _ is where you would name this. A member with no parentheses is a property, which is why Total and Increment() differ. New Counter() becomes just Counter().Interfaces
Interfaces exist and are the same .NET interfaces — with one rule that trips everybody once.
Option Strict On
Imports System
Imports System.Collections.Generic
Public Interface IGreeter
Function Greet(name As String) As String
End Interface
Public Class Formal
Implements IGreeter
Public Function Greet(name As String) As String Implements IGreeter.Greet
Return $"Good day, {name}."
End Function
End Class
Module InterfaceDemo
Sub Main()
Dim greeters As New List(Of IGreeter) From {New Formal()}
For Each greeter As IGreeter In greeters
Console.WriteLine(greeter.Greet("Ada"))
Next
End Sub
End Moduletype IGreeter =
abstract member Greet: string -> string
type Formal() =
interface IGreeter with
member _.Greet name = $"Good day, {name}."
let formal = Formal() :> IGreeter
printfn "%s" (formal.Greet "Ada")
// An object expression implements an interface with no class at all
let casual =
{ new IGreeter with
member _.Greet name = $"Hi {name}!" }
printfn "%s" (casual.Greet "Ada")Implements becomes interface ... with. The rule to know: F# implements interfaces explicitly by default, so the method is reachable only through the interface type — hence the upcast :> before calling it. The addition is the object expression: { new IGreeter with ... } creates a one-off implementation with no class declaration, which is what you use where C# would reach for a lambda or a tiny private class.Modules & .NET Interop
Module becomes module
The construct keeps its name and its meaning — a container of functions that need no instance.
Option Strict On
Imports System
Namespace Geometry
Public Module Area
Public Function Rectangle(width As Double, height As Double) As Double
Return width * height
End Function
End Module
End Namespace
Module ModuleDemo
Sub Main()
Console.WriteLine(Geometry.Area.Rectangle(3, 4))
End Sub
End Modulemodule Geometry =
module Area =
let rectangle width height = width * height
let unitName = "cm"
printfn "%f" (Geometry.Area.rectangle 3.0 4.0)
open Geometry
printfn "%s" unitName
printfn "%f" (Area.rectangle 2.0 5.0)A Visual Basic
Module and an F# module are the same idea and compile to the same thing: a static class. Imports becomes open, which brings a module or namespace's names into scope exactly as you expect. Modules nest, and a file with no module declaration gets one named after the file. This is the closest structural correspondence anywhere on the page — most of a Visual Basic Module ports almost line for line.The whole base class library is right there
Nothing you know about .NET is lost — this is the payoff for staying on the same runtime.
Option Strict On
Imports System
Imports System.Collections.Generic
Imports System.Text
Module InteropDemo
Sub Main()
Dim builder As New StringBuilder()
builder.Append("abc")
Dim lookup As New Dictionary(Of String, Integer)
lookup("one") = 1
Console.WriteLine(builder.ToString())
Console.WriteLine(lookup("one"))
Console.WriteLine(New DateTime(2026, 1, 1).Year)
End Sub
End Moduleopen System
open System.Collections.Generic
open System.Text
let builder = StringBuilder()
builder.Append("abc") |> ignore
let lookup = Dictionary<string, int>()
lookup.["one"] <- 1
printfn "%s" (builder.ToString())
printfn "%d" lookup.["one"]
printfn "%d" (DateTime(2026, 1, 1)).YearStringBuilder, Dictionary<K,V>, DateTime, every NuGet package and every class you have written are all available unchanged. Generic arguments use angle brackets rather than (Of ...), and an indexer is .[key]. The one new habit: F# insists every expression's value is used, so a method returning something you do not want — like StringBuilder.Append, which returns the builder — needs |> ignore or the compiler warns.Error Handling
Try/Catch becomes try/with
The same .NET exceptions, matched with the same pattern syntax as everything else — and
finally needs its own block.Option Strict On
Imports System
Module TryDemo
Sub Main()
Try
Dim value As Integer = Integer.Parse("not a number")
Console.WriteLine(value)
Catch error_ As FormatException
Console.WriteLine($"Bad format: {error_.Message}")
Catch error_ As Exception
Console.WriteLine($"Something else: {error_.Message}")
Finally
Console.WriteLine("always runs")
End Try
End Sub
End Moduleopen System
try
try
let value = Int32.Parse "not a number"
printfn "%d" value
with
| :? FormatException as error -> printfn "Bad format: %s" error.Message
| error -> printfn "Something else: %s" error.Message
finally
printfn "always runs"Catch x As T becomes an arm of a match: | :? FormatException as error ->, where :? is the type test. Throw becomes raise, and Throw New ArgumentException(...) becomes failwith "message" for the quick case. The one structural surprise: F# has no try/with/finally in a single block, so a try ... finally has to wrap a try ... with, as above.Failure as a value, not an exception
The
TryParse pattern, without the output parameter and with room for a reason.Option Strict On
Imports System
Module ResultDemo
Function TryDivide(numerator As Integer, denominator As Integer, ByRef result As Integer) As Boolean
If denominator = 0 Then Return False
result = numerator \ denominator
Return True
End Function
Sub Main()
Dim answer As Integer
If TryDivide(10, 2, answer) Then
Console.WriteLine(answer)
Else
Console.WriteLine("cannot divide")
End If
If Not TryDivide(10, 0, answer) Then Console.WriteLine("cannot divide")
End Sub
End Modulelet divide numerator denominator =
if denominator = 0 then Error "cannot divide by zero"
else Ok (numerator / denominator)
match divide 10 2 with
| Ok value -> printfn "%d" value
| Error message -> printfn "%s" message
match divide 10 0 with
| Ok value -> printfn "%d" value
| Error message -> printfn "%s" message
printfn "%A" (divide 10 2 |> Result.map (fun v -> v * 100))A
Result is Ok value or Error reason, and the compiler makes you handle both — so it is the Boolean-plus-ByRef idiom with the failure carrying information and the success value impossible to read by mistake. Result.map transforms a success and passes an error straight through, which lets a chain of fallible steps be written without a single If. Exceptions are still available and still right for genuinely exceptional things; Result is for failures you expect.Async and await
The same
Task, driven by a block rather than by two keywords.Option Strict On
Imports System
Imports System.Threading.Tasks
Module AsyncDemo
Async Function FetchAsync(label As String) As Task(Of String)
Await Task.Delay(10)
Return $"result for {label}"
End Function
Sub Main()
Dim value As String = FetchAsync("one").GetAwaiter().GetResult()
Console.WriteLine(value)
End Sub
End Moduleopen System.Threading.Tasks
let fetch label = task {
do! Task.Delay 10
return $"result for {label}"
}
let value = (fetch "one").Result
printfn "%s" value
let both =
Task.WhenAll [| fetch "one"; fetch "two" |]
printfn "%A" both.ResultAsync Function ... As Task(Of T) becomes task { ... }. Await splits in two: let! x = ... when you want the value and do! ... when you do not. Return becomes return. These are computation expressions, a general mechanism — async { }, seq { }, option { } and library-defined ones all use the same shape, which is why learning it once pays off repeatedly. Everything is still System.Threading.Tasks underneath, so it interoperates with Visual Basic and C# freely.⚠ Gotchas for Visual Basic Programmers
⚠ / truncates between integers
The same trap C# sets, made slightly louder by F#'s refusal to convert anything implicitly.
Option Strict On
Imports System
Module DivisionGotcha
Sub Main()
Dim average As Double = (3 + 4) / 2
Console.WriteLine(average)
End Sub
End Modulelet average = (3 + 4) / 2
printfn "%d" average
let correct = float (3 + 4) / 2.0
printfn "%f" correctVisual Basic reserves
\ for integer division; F# has no \, and / between two ints truncates. What saves you here more often than in C# is that F# will not silently widen: writing (3 + 4) / 2.0 is a compile error rather than a wrong answer, because you cannot divide an int by a float. You must convert explicitly, and being made to write float is exactly the prompt to think about it.⚠ Definition order matters, in files too
There is no forward reference, and the rule applies to whole files as well as to lines.
Option Strict On
Imports System
Module OrderDemo
Sub Main()
' Helper is defined BELOW and that is fine
Console.WriteLine(Helper(21))
End Sub
Function Helper(value As Integer) As Integer
Return value * 2
End Function
End Modulelet helper value = value * 2
// Using helper before this line would not compile
printfn "%d" (helper 21)F# compiles strictly top to bottom: a name must be defined before it is used. Inside a file that is a mild adjustment. Across a project it is a real one — the order of files in the project file is significant, and rearranging them can break the build. It feels restrictive at first and turns out to be a feature: the dependency graph is a straight line, so there are no circular references between modules, ever. Mutually recursive definitions, when genuinely needed, are joined with
and.⚠ let does not assign — it shadows
Writing
let twice with the same name compiles and looks like assignment — but it is not, and inside a loop that difference bites.Option Strict On
Imports System
Module ShadowGotcha
Sub Main()
Dim total As Integer = 1
total = total + 1
total = total + 1
Console.WriteLine(total)
End Sub
End Modulelet compute () =
let total = 1
let total = total + 1 // a NEW binding hiding the old one
let total = total + 1
total
printfn "%d" (compute ())
let mutable running = 1
running <- running + 1
printfn "%d" runningEach
let total = ... introduces a new binding that hides the previous one for the rest of the scope; the old value is untouched, and anything already capturing it still sees the old one. Inside a function the effect resembles assignment closely enough to mislead — and stops resembling it the moment a closure or a loop is involved, because the shadowed binding does not persist across iterations. (At module level F# refuses outright: two lets with one name is Duplicate definition of value, which is why the example puts them inside a function.) When you want a value that changes over time, say so with let mutable and <-.⚠ Every expression has a value, including nothing
A returned value cannot be silently thrown away, which turns a very ordinary .NET call into a compiler warning.
Option Strict On
Imports System
Imports System.Text
Module UnitGotcha
Sub Main()
Dim builder As New StringBuilder()
' The return value of Append is simply discarded
builder.Append("a")
builder.Append("b")
Console.WriteLine(builder.ToString())
End Sub
End Moduleopen System.Text
let builder = StringBuilder()
builder.Append("a") |> ignore
builder.Append("b") |> ignore
printfn "%s" (builder.ToString())F# expects every expression in a sequence to have type
unit — "no meaningful value". A method that returns something you do not want, which fluent .NET APIs do constantly, therefore needs |> ignore. It is noise the first few times and then becomes useful: the warning has caught a genuinely forgotten result at least once for everyone who writes F#. unit is written (), and a Sub becomes a function returning it.⚠ No My namespace, and no designer
The .NET half of what you use survives untouched; the Visual Basic-only half does not.
Option Strict On
Imports System
Module PlatformGotcha
Sub Main()
' My.Computer, My.Application and the WinForms designer
' are Visual Basic conveniences, not .NET ones
Console.WriteLine(Environment.MachineName.Length > 0)
Console.WriteLine(IsNumeric("42"))
Console.WriteLine(Now.Year > 2000)
End Sub
End Moduleopen System
printfn "%b" (Environment.MachineName.Length > 0)
printfn "%b" (fst (Int32.TryParse "42"))
printfn "%b" (DateTime.Now.Year > 2000)Everything under
System is unchanged — Environment, DateTime, Int32.TryParse — and note that F# turns an out parameter into part of a tuple, so TryParse returns (bool, int) and needs no ByRef. What is gone is the My namespace, the Microsoft.VisualBasic functions (IsNumeric, MsgBox, Now) and, in practice, the visual designer: F# has no supported WinForms designer, so a form is written in code or the application becomes a web service. That is the real cost of this move, and it is worth weighing before starting.