PONY λ M2 Modula-2

C#.CodeCompared.To/F#

An interactive executable cheatsheet comparing C# and F#

C# 13 F# (.NET 9)
Hello World & Output
Hello, World
Console.WriteLine("Hello, World!");
printfn "Hello, World!"
printfn is F#'s equivalent of Console.WriteLine — it prints a string followed by a newline. F# files are executed top-to-bottom without requiring a class or main method; there are no braces or semicolons.
Formatted output
var name = "F#"; var version = 9; Console.WriteLine($"Hello from {name} {version}!");
let name = "F#" let version = 9 printfn "Hello from %s %d!" name version
printfn uses %s, %d, %f format specifiers (like C's printf), not C#'s string interpolation. Arguments are passed after the format string, separated by spaces — no parentheses needed for a curried function call. F# also supports $"..." string interpolation for simple cases.
String interpolation
var width = 5.5; var height = 3.2; var description = $"Area: {width * height:F2}"; Console.WriteLine(description);
let width = 5.5 let height = 3.2 let description = $"Area: {width * height:F2}" printfn "%s" description
F# supports C#-style $"..." string interpolation. Format specifiers inside {} use .NET format strings, just like C#. Unlike C# where :F2 specifies 2 decimal places, F# uses the standard .NET format string directly inside the braces.
Multiline strings
var json = """ { "name": "Alice", "age": 30 } """; Console.WriteLine(json);
let json = """ { "name": "Alice", "age": 30 } """ printfn "%s" json
F# supports triple-quoted strings """...""" for multiline content, matching C#'s raw string literals. F# also has verbatim strings @"..." (no backslash escaping) and triple-quoted strings are the idiomatic choice for embedded content like JSON or SQL.
Variables & Types
Immutable bindings with let
var greeting = "Hello"; var count = 42; Console.WriteLine($"{greeting}, count={count}");
let greeting = "Hello" let count = 42 printfn "%s, count=%d" greeting count
let bindings in F# are immutable by default — there is no reassignment after binding. This is the opposite of C#'s var, which declares a mutable variable. Attempting to reassign a let binding is a compile error. Use let mutable for rare cases where mutation is needed.
Mutable bindings
var counter = 0; counter += 1; counter += 1; Console.WriteLine(counter);
let mutable counter = 0 counter <- counter + 1 counter <- counter + 1 printfn "%d" counter
let mutable declares a mutable binding; assignment uses <- (not =, which is equality in F#). Mutable variables are used sparingly in F# — idiomatic code prefers immutable values and expressions that produce new values, rather than updating existing ones.
Type annotations
int count = 42; string greeting = "Hello"; double ratio = 3.14; Console.WriteLine($"{greeting} {count} {ratio}");
let count: int = 42 let greeting: string = "Hello" let ratio: float = 3.14 printfn "%s %d %f" greeting count ratio
F# type annotations are written after the name with a colon: let name: Type. They are rarely needed because F#'s type inference is very strong — the compiler almost always infers the correct type. Unlike C#, explicit annotations are considered a code smell when unnecessary; they are used mainly when the compiler cannot infer the type or for documentation.
Unit type (equivalent of void)
void PrintGreeting(string name) => Console.WriteLine($"Hello, {name}!"); PrintGreeting("Alice");
let printGreeting name = printfn "Hello, %s!" name printGreeting "Alice"
F# has no void — functions that produce no meaningful value return unit, a type with a single value written (). printfn returns unit. In F#, everything is an expression, so "statements" are just expressions that return unit.
Tuples
var point = (3.0, 4.0); Console.WriteLine($"x={point.Item1}, y={point.Item2}"); var (x, y) = point; Console.WriteLine($"x={x}, y={y}");
let point = (3.0, 4.0) printfn "x=%f, y=%f" (fst point) (snd point) let (x, y) = point printfn "x=%f, y=%f" x y
Tuples are a first-class type in both languages. F# uses fst and snd for two-element tuples, or pattern-match to destructure. Tuples are written without the ValueTuple syntax — (a, b) is a native F# tuple literal. F# tuples are passed as multiple arguments when calling .NET methods.
Strings
String operations
var text = " Hello, F#! "; Console.WriteLine(text.Trim()); Console.WriteLine(text.Trim().ToUpper()); Console.WriteLine(text.Trim().Replace("F#", "World"));
let text = " Hello, F#! " printfn "%s" (text.Trim()) printfn "%s" (text.Trim().ToUpper()) printfn "%s" (text.Trim().Replace("F#", "World"))
F# has full access to .NET String methods via dot notation. F# also has its own String module: String.length, String.concat, String.split, etc. The F# module functions are often preferred in functional pipelines, while .NET method calls are used for compatibility.
String concatenation
var firstName = "Alice"; var lastName = "Smith"; var fullName = firstName + " " + lastName; Console.WriteLine(fullName);
let firstName = "Alice" let lastName = "Smith" let fullName = firstName + " " + lastName printfn "%s" fullName
String concatenation with + works identically. F# also provides String.concat separator list for joining a list of strings, and the sprintf function to build formatted strings without printing them (returning a string, unlike printfn which returns unit).
Splitting and joining
var csv = "apple,banana,cherry"; var fruits = csv.Split(','); Console.WriteLine(fruits.Length); Console.WriteLine(string.Join(" | ", fruits));
let csv = "apple,banana,cherry" let fruits = csv.Split(',') printfn "%d" fruits.Length printfn "%s" (String.concat " | " fruits)
String.concat delimiter sequence is F#'s idiomatic join — the argument order is reversed compared to C#'s string.Join(delimiter, items). F# puts the "separator" first and the collection second, consistent with how curried functions are partially applied.
Numbers & Math
Arithmetic and number types
Console.WriteLine(10 + 3); Console.WriteLine(10 - 3); Console.WriteLine(10 * 3); Console.WriteLine(10 / 3); Console.WriteLine(10 % 3); Console.WriteLine(10.0 / 3.0);
printfn "%d" (10 + 3) printfn "%d" (10 - 3) printfn "%d" (10 * 3) printfn "%d" (10 / 3) printfn "%d" (10 % 3) printfn "%f" (10.0 / 3.0)
F# is strict about numeric types — you cannot mix int and float in arithmetic without explicit conversion. 10 / 3 is integer division (result: 3); 10.0 / 3.0 is float division. Use float n or int f to convert. C# allows implicit widening (int → double); F# does not.
Numeric type conversion
int counter = 5; double ratio = (double)counter / 2.0; Console.WriteLine(ratio);
let counter = 5 let ratio = float counter / 2.0 printfn "%f" ratio
F# uses conversion functions (int, float, string, char, int64) rather than cast syntax. float counter converts the integer to a float. This makes type coercion visible and intentional — a common source of bugs in C# is accidental integer truncation from an implicit narrowing conversion.
Math functions
Console.WriteLine(Math.Sqrt(16.0)); Console.WriteLine(Math.Pow(2.0, 10.0)); Console.WriteLine(Math.Abs(-42));
printfn "%f" (sqrt 16.0) printfn "%f" (2.0 ** 10.0) printfn "%d" (abs -42)
F# exposes common math functions as top-level functions: sqrt, abs, sin, cos, log, exp. The ** operator raises to a power (both float). The full .NET Math class is also available for less common operations.
Collections
Immutable lists
var fruits = new List<string> { "apple", "banana", "cherry" }; Console.WriteLine(fruits.Count); Console.WriteLine(fruits[0]);
let fruits = ["apple"; "banana"; "cherry"] printfn "%d" (List.length fruits) printfn "%s" (List.head fruits)
F# lists ([a; b; c]) are immutable singly-linked lists — not the same as .NET's List<T>. Elements are separated by semicolons. Operations like List.filter, List.map, and List.length return new lists; nothing mutates in place. For a mutable .NET list, use System.Collections.Generic.List<T>.
Building lists with cons
var numbers = new List<int> { 1, 2, 3 }; numbers.Insert(0, 0); Console.WriteLine(string.Join(", ", numbers));
let numbers = [1; 2; 3] let extended = 0 :: numbers printfn "%A" extended
The :: operator (cons) prepends an element to a list, returning a new list. This is O(1) because F# lists are linked. Appending to the end (@ operator) is O(n). The %A format specifier prints any F# value using its structural representation — useful for debugging lists, records, and unions.
Arrays (mutable)
var numbers = new int[] { 1, 2, 3, 4, 5 }; numbers[0] = 99; Console.WriteLine(numbers[0]); Console.WriteLine(numbers.Length);
let numbers = [| 1; 2; 3; 4; 5 |] numbers.[0] <- 99 printfn "%d" numbers.[0] printfn "%d" numbers.Length
F# arrays ([|...|]) are mutable, fixed-size, and map directly to .NET arrays — unlike F# lists. Index with arr.[i] (the dot is optional in modern F# but traditional). Assign with <-. Arrays are preferred over lists when random access or mutation is needed.
Sequences (lazy)
var evens = Enumerable.Range(0, 10) .Where(n => n % 2 == 0) .ToList(); Console.WriteLine(string.Join(", ", evens));
let evens = seq { 0 .. 18 } |> Seq.filter (fun n -> n % 2 = 0) |> Seq.toList printfn "%A" evens
F# seq { ... } is a lazy sequence equivalent to LINQ's IEnumerable<T>. The Seq module provides filter, map, fold, take, etc. The pipe operator |> threads the sequence through the pipeline — read left-to-right, much like LINQ method chains.
Maps (immutable dictionaries)
var scores = new Dictionary<string, int> { { "Alice", 95 }, { "Bob", 87 }, }; Console.WriteLine(scores["Alice"]);
let scores = Map.ofList [("Alice", 95); ("Bob", 87)] printfn "%d" scores.["Alice"]
F#'s Map is an immutable balanced tree map. "Updating" a map returns a new map with the change: Map.add "Carol" 91 scores. Use Map.tryFind key map to safely look up a key — it returns Some value or None instead of throwing an exception.
Control Flow
If as an expression
var score = 75; var grade = score >= 60 ? "Pass" : "Fail"; Console.WriteLine(grade);
let score = 75 let grade = if score >= 60 then "Pass" else "Fail" printfn "%s" grade
In F#, if/then/else is an expression that returns a value — not a statement. There is no ternary operator (? :); if/then/else serves the same role and is more readable. Both branches must return the same type (or unit if there is no else).
Multiline if / elif / else
var temperature = 22; if (temperature > 30) Console.WriteLine("Hot"); else if (temperature > 20) Console.WriteLine("Warm"); else Console.WriteLine("Cool");
let temperature = 22 if temperature > 30 then printfn "Hot" elif temperature > 20 then printfn "Warm" else printfn "Cool"
F# uses elif (not else if) for chained conditions. There are no parentheses around conditions and no braces around bodies — indentation defines scope. All branches of a multiline if expression must return the same type.
For loops
for (int i = 0; i < 5; i++) Console.WriteLine(i);
for i in 0 .. 4 do printfn "%d" i
F# for loops use range syntax: for i in start .. end do (inclusive on both ends). Use start .. step .. end for a step: 0 .. 2 .. 10. Iterate over a collection with for item in collection do. F# for loops return unit — they are imperative; use List.map / List.iter in functional code.
While loops
var counter = 0; while (counter < 3) { Console.WriteLine(counter); counter++; }
let mutable counter = 0 while counter < 3 do printfn "%d" counter counter <- counter + 1
F# has while condition do body loops. Since while depends on mutable state, it is considered imperative and used sparingly in functional F# code. Recursive functions are often the idiomatic alternative — they compute results without mutation.
Functions
Defining functions
static int Add(int first, int second) => first + second; static string Greet(string name) => $"Hello, {name}!"; Console.WriteLine(Add(3, 4)); Console.WriteLine(Greet("World"));
let add first second = first + second let greet name = sprintf "Hello, %s!" name printfn "%d" (add 3 4) printfn "%s" (greet "World")
F# functions are defined with let name param1 param2 = body — no return type, no return keyword, no parens around parameters, no braces. The function body is the last expression; its value is the return value. All functions in F# are automatically curried — calling add 3 returns a new function waiting for the second argument.
Currying and partial application
// C# partial application requires a closure: static Func<int, int> AddN(int n) => x => x + n; var add5 = AddN(5); Console.WriteLine(add5(10)); Console.WriteLine(add5(20));
let add n x = n + x let add5 = add 5 // partial application — no extra closure needed printfn "%d" (add5 10) printfn "%d" (add5 20)
F# functions are curried by default — every function is technically a function of one argument that returns another function. Calling add 5 applies the first argument and returns a new function int -> int. This makes partial application trivial and enables point-free style. In C# you must explicitly construct a closure.
Recursive functions
static int Factorial(int n) => n <= 1 ? 1 : n * Factorial(n - 1); Console.WriteLine(Factorial(10));
let rec factorial n = if n <= 1 then 1 else n * factorial (n - 1) printfn "%d" (factorial 10)
Recursive functions must use the rec keyword in F#. Without it, the function name is not in scope inside the body and will not compile. This explicit annotation makes recursion visible. Mutually recursive functions use let rec f = ... and g = ....
Local functions
static int SumDigits(int number) { static int Sum(int n, int acc) => n == 0 ? acc : Sum(n / 10, acc + n % 10); return Sum(Math.Abs(number), 0); } Console.WriteLine(SumDigits(12345));
let sumDigits number = let rec loop n accumulator = if n = 0 then accumulator else loop (n / 10) (accumulator + n % 10) loop (abs number) 0 printfn "%d" (sumDigits 12345)
F# allows nested let definitions inside functions — local functions are idiomatic, especially for internal recursive helpers (loop is a common name). The inner function has access to the outer function's parameters, like a closure. The outer function "returns" the last expression in its body, which here is the call to loop.
Higher-Order Functions
Lambda expressions
Func<int, int> doubler = x => x * 2; Console.WriteLine(doubler(5)); var numbers = new[] { 1, 2, 3, 4, 5 }; var doubled = numbers.Select(x => x * 2).ToList(); Console.WriteLine(string.Join(", ", doubled));
let doubler = fun x -> x * 2 printfn "%d" (doubler 5) let numbers = [1; 2; 3; 4; 5] let doubled = numbers |> List.map (fun x -> x * 2) printfn "%A" doubled
F# lambda syntax is fun param1 param2 -> body. The pipe operator |> passes the left-hand value as the last argument to the right-hand function. Pipelines read left-to-right: start with data, then apply transformations in order — the F# idiom for what C# expresses as LINQ method chains.
Map, filter, and fold (reduce)
var numbers = new[] { 1, 2, 3, 4, 5, 6 }; var evenSquaresSum = numbers .Where(n => n % 2 == 0) .Select(n => n * n) .Sum(); Console.WriteLine(evenSquaresSum);
let numbers = [1; 2; 3; 4; 5; 6] let result = numbers |> List.filter (fun n -> n % 2 = 0) |> List.map (fun n -> n * n) |> List.sum printfn "%d" result
List.filter corresponds to LINQ's Where; List.map to Select; List.sum to .Sum(); List.fold to Aggregate. The pipe operator makes F# pipelines read top-to-bottom — often clearer than reading chained method calls inside-out.
Fold (accumulate)
var numbers = new[] { 1, 2, 3, 4, 5 }; var product = numbers.Aggregate(1, (acc, n) => acc * n); Console.WriteLine(product);
let numbers = [1; 2; 3; 4; 5] let product = List.fold (fun accumulator n -> accumulator * n) 1 numbers printfn "%d" product
List.fold accumulator-fn initial collection corresponds to LINQ's Aggregate(seed, func). The accumulator function takes the accumulated value first, then the current element. List.foldBack folds from right to left. F# also has List.reduce for when there is no initial value.
Function composition
Func<int, int> doubler = x => x * 2; Func<int, int> addTen = x => x + 10; Func<int, int> combined = x => addTen(doubler(x)); Console.WriteLine(combined(5));
let doubler x = x * 2 let addTen x = x + 10 let combined = doubler >> addTen // compose: apply doubler, then addTen printfn "%d" (combined 5)
F# has a built-in function composition operator >> (forward composition). f >> g creates a new function that applies f then g. The reverse << applies right-to-left like mathematical function composition. This is more concise than nesting lambdas in C#.
Pattern Matching
Match expressions
var day = "Monday"; var message = day switch { "Saturday" or "Sunday" => "Weekend!", "Monday" => "Back to work.", _ => "Weekday", }; Console.WriteLine(message);
let day = "Monday" let message = match day with | "Saturday" | "Sunday" -> "Weekend!" | "Monday" -> "Back to work." | _ -> "Weekday" printfn "%s" message
F#'s match ... with expression is the core of the language. Each arm is | pattern -> result. The compiler enforces exhaustiveness — missing a case is a warning (and often an error). The wildcard _ matches anything. Multiple patterns on one arm use | (not or).
When guards
int score = 85; var grade = score switch { >= 90 => "A", >= 80 => "B", >= 70 => "C", _ => "F", }; Console.WriteLine(grade);
let score = 85 let grade = match score with | s when s >= 90 -> "A" | s when s >= 80 -> "B" | s when s >= 70 -> "C" | _ -> "F" printfn "%s" grade
when condition guards add a boolean test to a match arm. The arm only matches if both the pattern and the guard are true. Unlike C#'s relational patterns (>= 90), F# requires an explicit when guard — there are no standalone relational patterns.
Matching on tuples
for (int i = 1; i <= 15; i++) Console.WriteLine(FizzBuzz(i)); string FizzBuzz(int n) => (n % 3 == 0, n % 5 == 0) switch { (true, true) => "FizzBuzz", (true, false) => "Fizz", (false, true) => "Buzz", _ => n.ToString(), };
let fizzBuzz n = match n % 3 = 0, n % 5 = 0 with | true, true -> "FizzBuzz" | true, false -> "Fizz" | false, true -> "Buzz" | _, _ -> string n for i in 1 .. 15 do printfn "%s" (fizzBuzz i)
Matching on a tuple of conditions is a common F# idiom for multi-way dispatch based on multiple booleans or values. Each arm destructures the tuple in place. This is more readable than nested if/elif chains and makes the decision table visually explicit.
List patterns
static string Describe(List<int> items) => items switch { [] => "empty", [var only] => $"one item: {only}", [var first, ..] => $"starts with {first}", }; Console.WriteLine(Describe([])); Console.WriteLine(Describe([42])); Console.WriteLine(Describe([1, 2, 3]));
let describe items = match items with | [] -> "empty" | [only] -> sprintf "one item: %d" only | first :: _ -> sprintf "starts with %d" first printfn "%s" (describe []) printfn "%s" (describe [42]) printfn "%s" (describe [1; 2; 3])
F# list patterns are integral to the language. [] matches an empty list; [x] matches exactly one element; x :: rest matches head and tail (any non-empty list). Matching on list structure is idiomatic in recursive functions that process lists element-by-element.
Discriminated Unions
Discriminated unions
// C# uses sealed classes/interfaces for this pattern (Java-like): abstract class Shape { } sealed class Circle : Shape { public double Radius { get; init; } } sealed class Rectangle : Shape { public double Width { get; init; } public double Height { get; init; } }
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 5.0)) printfn "%.2f" (area (Rectangle (4.0, 3.0)))
Discriminated unions (DUs) are F#'s native algebraic data type — a type that is exactly one of several named cases, each optionally carrying data. They replace the sealed class hierarchy pattern C# 9+ added. Pattern matching over a DU is exhaustive: the compiler warns if you miss a case. This is F#'s most distinctive and powerful feature.
Unions with no data (enum-like)
var heading = Direction.North; Console.WriteLine(heading); enum Direction { North, South, East, West }
type Direction = North | South | East | West let heading = North printfn "%A" heading
A discriminated union with no attached data on each case is equivalent to an enum. Unlike C# enums, F# DU cases are not integers — they are named constructors of the union type. They can be extended with data at any time without breaking existing pattern matches (the compiler will flag unmatched cases).
Recursive discriminated unions (trees)
// C# linked list via class hierarchy: abstract class IntList { } class Empty : IntList { } class Cons : IntList { public int Head; public IntList Tail = null!; }
type IntList = | Empty | Cons of head: int * tail: IntList let myList = Cons (1, Cons (2, Cons (3, Empty))) let rec sum list = match list with | Empty -> 0 | Cons (head, tail) -> head + sum tail printfn "%d" (sum myList)
Discriminated unions can be recursive — a case can reference the union type itself. This makes it natural to define linked lists, trees, and expression trees as types. Recursive DUs and recursive functions with pattern matching are the idiomatic F# alternative to class hierarchies and virtual dispatch.
Records
Records
var person = new Person("Alice", 30); Console.WriteLine($"{person.Name} is {person.Age}"); record Person(string Name, int Age);
type Person = { Name: string; Age: int } let person = { Name = "Alice"; Age = 30 } printfn "%s is %d" person.Name person.Age
F# records use type Name = { Field: Type; ... } for declaration and { Field = value; ... } for creation. They are immutable by default with auto-generated structural equality and ToString(). Fields are labeled (unlike tuples), and the compiler infers the record type from the field names.
Record update (copy with changes)
var alice = new Person("Alice", 30); var olderAlice = alice with { Age = 31 }; Console.WriteLine(olderAlice); record Person(string Name, int Age);
type Person = { Name: string; Age: int } let alice = { Name = "Alice"; Age = 30 } let olderAlice = { alice with Age = 31 } printfn "%A" olderAlice
F# record update syntax { existing with Field = newValue } creates a new record with one or more fields changed — equivalent to C#'s with expression for records. All unspecified fields are copied from the original. This is the primary way to "update" immutable records.
Record pattern matching
object value = new Point(3.0, 4.0); if (value is Point { X: var x, Y: var y }) Console.WriteLine($"Point at ({x}, {y})"); record Point(double X, double Y);
type Point = { X: float; Y: float } let value = { X = 3.0; Y = 4.0 } let { X = x; Y = y } = value printfn "Point at (%f, %f)" x y
F# records can be destructured directly with let { Field = binding } = record or in match arms. Unlike C# property patterns ({ X: var x }), F# record patterns bind by writing Field = binding. All fields need not be bound — unmentioned fields are ignored in a match arm.
Option & Result
Option type (no nulls)
string? FindUser(int id) => id == 1 ? "Alice" : null; var user = FindUser(1); Console.WriteLine(user?.ToUpper() ?? "Not found"); var missing = FindUser(99); Console.WriteLine(missing?.ToUpper() ?? "Not found");
let findUser id = if id = 1 then Some "Alice" else None let display (result: string option) = match result with | Some name -> name.ToUpper() | None -> "Not found" printfn "%s" (findUser 1 |> display) printfn "%s" (findUser 99 |> display)
F# does not allow null for its own types by default. Instead, optional values use Option<'T>, a discriminated union with cases Some value and None. This makes the possibility of absence explicit in the type and forces pattern matching, eliminating null-reference exceptions at compile time.
Transforming Option values
string? name = "Alice"; var length = name?.Length; // int? Console.WriteLine(length ?? 0);
let name = Some "Alice" let length = name |> Option.map (fun s -> s.Length) printfn "%A" length printfn "%d" (Option.defaultValue 0 length)
Option.map f opt applies f to the inner value if Some, or returns None if None — equivalent to C#'s ?.Length. Option.defaultValue fallback opt unwraps with a default, matching ?? fallback. Chaining Option.bind (flatMap) avoids nested Some(Some(...)).
Result type (typed errors)
// C# typically uses exceptions for errors: static int Divide(int a, int b) { if (b == 0) throw new DivideByZeroException(); return a / b; } try { Console.WriteLine(Divide(10, 2)); } catch { Console.WriteLine("Error: division by zero"); }
let divide a b = if b = 0 then Error "division by zero" else Ok (a / b) match divide 10 2 with | Ok result -> printfn "%d" result | Error msg -> printfn "Error: %s" msg match divide 10 0 with | Ok result -> printfn "%d" result | Error msg -> printfn "Error: %s" msg
Result<'TSuccess, 'TError> is a discriminated union with cases Ok value and Error error. It makes error handling explicit in the return type — callers cannot ignore the possibility of failure. The Result module provides Result.map, Result.bind, and Result.defaultValue for chaining computations.
Exceptions (when needed)
try { var number = int.Parse("abc"); Console.WriteLine(number); } catch (FormatException ex) { Console.WriteLine($"Error: {ex.Message}"); }
try let number = System.Int32.Parse("abc") printfn "%d" number with | :? System.FormatException as ex -> printfn "Error: %s" ex.Message
F# can catch .NET exceptions with try ... with | :? ExceptionType as e ->. The :? pattern matches a .NET exception type. F# also has its own exceptions defined with exception MyError of string, which can be raised with raise (MyError "msg") and matched without :?.
Pipelines & Composition
The pipe operator |>
// C# method chaining reads left-to-right for the data: var result = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 } .Where(n => n % 2 == 0) .Select(n => n * n) .ToList(); Console.WriteLine(string.Join(", ", result));
let result = [1 .. 10] |> List.filter (fun n -> n % 2 = 0) |> List.map (fun n -> n * n) printfn "%A" result
The pipe operator |> threads a value as the last argument to the next function. value |> f is equivalent to f value. This lets you write data transformations top-to-bottom in the order they happen, which is often clearer than reading nested function calls inside-out. The pipe replaces LINQ method chains.
Building strings in pipelines
var result = new[] { 1, 2, 3, 4, 5 } .Select(n => n * n) .Select(n => n.ToString()) .Aggregate((a, b) => a + ", " + b); Console.WriteLine(result);
let result = [1 .. 5] |> List.map (fun n -> n * n) |> List.map string |> String.concat ", " printfn "%s" result
string is a conversion function in F# (converts any value to its string representation), so List.map string is a concise alternative to List.map (fun n -> string n). This use of string as a function (not a type keyword) surprises C# developers at first.
Async workflows
async Task<string> FetchGreetingAsync() { await Task.Delay(1); return "Hello from async!"; } var message = await FetchGreetingAsync(); Console.WriteLine(message);
let fetchGreetingAsync () = async { do! Async.Sleep 1 return "Hello from async!" } let message = fetchGreetingAsync () |> Async.RunSynchronously printfn "%s" message
F# uses computation expressionsasync { ... } — for asynchronous code, rather than the async/await keywords. Inside the block, let! awaits an Async<'T> (equivalent to C#'s await), and do! awaits a unit-returning async. Async.RunSynchronously blocks a thread — use Async.StartAsTask to interop with .NET Tasks.
Classes & Interfaces
Classes in F#
var animal = new Animal("Dog"); Console.WriteLine(animal.Speak()); class Animal { public string Name { get; } public Animal(string name) { Name = name; } public virtual string Speak() => $"{Name} makes a sound"; }
type Animal(name: string) = member _.Name = name member _.Speak() = sprintf "%s makes a sound" name let animal = Animal "Dog" printfn "%s" (animal.Speak())
F# supports classes with constructor arguments declared in the type header (type Name(params) = ...). Members are member self.Method() = ...; the self identifier is any name (often this or _ if unused). Classes are used sparingly in F# — records and modules (functions + types) are preferred for most patterns.
Interfaces
IShape circle = new Circle(5); Console.WriteLine($"{circle.Area():F2}"); interface IShape { double Area(); } class Circle : IShape { double radius; public Circle(double radius) { this.radius = radius; } public double Area() => Math.PI * radius * radius; }
type IShape = abstract member Area: unit -> float type Circle(radius: float) = interface IShape with member _.Area() = System.Math.PI * radius * radius let circle = Circle 5.0 let area = (circle :> IShape).Area() printfn "%.2f" area
F# implements interfaces with interface IName with member _.Method() = .... To call an interface method, upcast with :> (the explicit upcast operator). F# does not implicitly upcast to interfaces the way C# does — you must write (circle :> IShape).Area(). Object expressions ({ new IName with ... }) provide anonymous interface implementations without a named class.
Gotchas for C# Developers
= is equality, not assignment
var x = 5; var y = 5; Console.WriteLine(x == y); Console.WriteLine(x != y);
let x = 5 let y = 5 printfn "%b" (x = y) // = is equality, not assignment printfn "%b" (x <> y) // <> is not-equal, not !=
F# uses = for equality comparison and <> for inequality — there is no == or !=. Assignment to a mutable variable uses <-. This trips up C# developers constantly: writing x = 5 inside a function body is a comparison that returns bool, not an assignment.
Semicolons are separators, not terminators
// C# semicolons end statements: var a = 1; var b = 2; Console.WriteLine(a + b);
// F# uses newlines and indentation, not semicolons: let a = 1 let b = 2 printfn "%d" (a + b) // Semicolons CAN separate expressions on one line: let result = let c = 3 in c + 1 printfn "%d" result
F# is indentation-sensitive — blocks are defined by consistent indentation, not braces or semicolons. Semicolons can appear as expression separators on one line, but are rare. This means a misindented line is a different expression, not just a style issue. The F# compiler will often give a confusing type error when indentation is wrong.
The last expression is the return value
static int Max(int first, int second) { if (first > second) return first; return second; } Console.WriteLine(Max(3, 7));
let max first second = if first > second then first else second printfn "%d" (max 3 7)
F# has no return statement — the value of the last expression in a function body is automatically the return value. Every if/then/else, match, and block is an expression. C# developers often accidentally add extra unit-returning expressions after the intended return value, causing a type error.
Definitions must appear before use
// C# allows forward references — order doesn't matter: Console.WriteLine(Add(3, 4)); static int Add(int first, int second) => first + second;
// F# requires top-to-bottom order — must define before using: let add first second = first + second printfn "%d" (add 3 4)
In F#, bindings must be defined before they are used — there are no forward references. Within a file, the order of let bindings matters. Across files, the file order in the project matters too. Mutually recursive definitions use let rec f = ... and g = .... This restriction makes the dependency graph explicit and prevents circular dependencies.
No implicit numeric conversion
int counter = 5; double ratio = counter / 2.0; // int silently widened to double Console.WriteLine(ratio);
let counter = 5 // let ratio = counter / 2.0 // compile error: int vs float let ratio = float counter / 2.0 // explicit conversion required printfn "%f" ratio
F# performs no implicit numeric conversions — mixing int and float in an expression is a compile error. You must explicitly convert with float, int, int64, etc. This prevents accidental integer truncation or precision loss that silently affects C# code.
.NET interop: using C# libraries in F#
var numbers = new List<int> { 1, 2, 3 }; numbers.Sort(); Console.WriteLine(string.Join(", ", numbers));
open System.Collections.Generic let numbers = List<int>() numbers.Add(1) numbers.Add(2) numbers.Add(3) numbers.Sort() printfn "%s" (System.String.Join(", ", numbers))
F# runs on .NET and has full access to .NET libraries. Use open Namespace (equivalent to using) to bring types into scope. C# classes, generics, and LINQ all work from F#. The friction is at mutable .NET APIs — they work, but feel out of place in functional F# code. Prefer F#'s own immutable collections when not interoperating.