PONYλM2Modula-2

Visual Basic.CodeCompared.To/F#

An interactive executable cheatsheet comparing Visual Basic and F#

Visual Basic (.NET 10) F# (.NET 10)
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 Module
printfn "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 Module
let 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 Module
let 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 // 0
A 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 Module
let 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 Module
let customerName = "Grace" let CustomerName = "Hopper" printfn "%s" customerName printfn "%s" CustomerName
Identifiers 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 Module
let count = 10 // count <- 20 // error: count is not mutable let mutable total = 10 total <- 20 let label = "widget" printfn "%d %d %s" count total label
Dim becomes let, and a let binding cannot be reassignedlet 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 Module
let 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 Module
let 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 asInt
Integerint, Longint64 (L suffix), Doublefloat, Singlefloat32, Decimaldecimal (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 Module
let 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 Module
let 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 Module
let 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 Module
let 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 squared
There 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 Module
let 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 Module
let quoted = "She said \"hello\"." let path = @"C:\reports\summary.txt" let block = """line one line two""" printfn "%s" quoted printfn "%s" path printfn "%s" block
Unlike 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 Module
let 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" appended
An 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 Module
let 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)
WhereList.filter, SelectList.map, AnyList.exists, AllList.forall, FirstOrDefaultList.tryFind (which returns an option), OrderByList.sortBy, AggregateList.fold, GroupByList.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 Module
let 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 Module
let score = 72 let grade = if score >= 90 then "A" elif score >= 70 then "B" else "F" printfn "%s" grade
ElseIf 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 Module
let 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 xmatch 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 Module
for 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" remaining
For 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 Module
let 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 Module
let 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 Module
let 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 Module
let 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 Module
type 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" older
A 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 Module
type 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 Module
let 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" speed
Attaching 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 Module
type Counter() = let mutable total = 0 member _.Increment() = total <- total + 1 member _.Total = total let counter = Counter() counter.Increment() counter.Increment() printfn "%d" counter.Total
The 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 Module
type 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 Module
module 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 Module
open 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)).Year
StringBuilder, 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 Module
open 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 Module
let 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 Module
open 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.Result
Async 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 Module
let average = (3 + 4) / 2 printfn "%d" average let correct = float (3 + 4) / 2.0 printfn "%f" correct
Visual 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 Module
let 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 Module
let 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" running
Each 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 Module
open 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 Module
open 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.