PONY λ M2 Modula-2

Ruby.CodeCompared.To/F#

An interactive executable cheatsheet for Rubyists learning F#

Ruby 4.0 F# (.NET 9)
Output & Basics
Hello World
puts "Hello, World!"
printfn "Hello, World!"

printfn prints a string followed by a newline — the direct equivalent of Ruby's puts. F# top-level expressions execute in order, just like a Ruby script.

Multiple outputs
puts "First" puts "Second" puts "Third"
printfn "First" printfn "Second" printfn "Third"

F# top-level code runs sequentially. Each printfn call is an IO action — the program executes them in order, like Ruby's procedural style.

print vs printfn
print "no newline" print "\n" puts "with newline"
printf "no newline" printf "\n" printfn "with newline"

printf is like Ruby's print — no trailing newline. printfn is like puts — adds a newline. Both accept format specifiers like %s, %d, and %A.

Format specifiers
name = "Alice" age = 30 price = 9.99 puts "#{name}, age #{age}, paid $#{format('%.2f', price)}"
let name = "Alice" let age = 30 let price = 9.99 printfn "%s, age %d, paid $%.2f" name age price

F# inherits printf-style format specifiers: %s for strings, %d for integers, %f for floats, %b for booleans, and %A for any value using F#'s default pretty-printer.

String interpolation
name = "Alice" age = 30 puts "Hello, #{name}! You are #{age} years old."
let name = "Alice" let age = 30 printfn $"Hello, {name}! You are {age} years old."

F# 5+ supports string interpolation with $"..." syntax, equivalent to Ruby's #{}. Any expression is valid inside the braces. For format control, use $"%.2f{price}" or switch to format specifiers.

Values & Bindings
Immutable binding
name = "Alice" puts name
let name = "Alice" printfn "%s" name

In F#, let creates an immutable binding — name cannot be reassigned. All let bindings are immutable by default, unlike Ruby's local variables. Attempting name <- "Bob" is a compile error.

Mutable binding
counter = 0 counter = 5 puts counter
let mutable counter = 0 counter <- 5 printfn "%d" counter

F# requires the mutable keyword to opt in to mutability. Assignment to a mutable binding uses <- — not =. The = operator is always equality comparison in F#.

Type inference
number = 42 greeting = "hello" decimal = 3.14 flag = true puts [number.class, greeting.class, decimal.class, flag.class].inspect
let number = 42 // inferred: int let greeting = "hello" // inferred: string let decimal = 3.14 // inferred: float let flag = true // inferred: bool printfn "%A" (number, greeting, decimal, flag)

F#'s Hindley-Milner type inference deduces the type of every binding without annotations. Unlike Ruby's dynamic typing, these types are checked statically at compile time — number + greeting is a compile error, not a runtime one.

Type annotations
# Ruby has no built-in type annotations (Sorbet/RBS are external tools) age = 30 greeting = "Hello" puts "#{greeting}, age #{age}"
let age: int = 30 let greeting: string = "Hello" printfn "%s, age %d" greeting age

Type annotations use a colon after the identifier name. They are optional — the compiler infers types — but useful for documentation or when the compiler needs a hint. F# annotations always follow the name, never precede it as in C# or Java.

Binding shadowing
# Ruby rebinds (mutates) the same variable x = 1 x = x + 1 puts x
// Shadowing works inside a function or let..in expression: let compute () = let number = 1 let number = number + 1 // shadows the first binding number printfn "%d" (compute ())

F# allows a let binding to shadow a previous one with the same name within a function scope. This creates a new immutable binding — the original is hidden, not mutated. Shadowing is common in F# pipeline code as an alternative to mutable variables.

Types
Numeric types
puts 42.class # Integer puts 3.14.class # Float puts 1_000_000.class # Integer
let integer = 42 // int (32-bit) let large = 1_000_000L // int64 (64-bit, L suffix) let floating = 3.14 // float (64-bit double) let single = 3.14f // float32 (32-bit, f suffix) printfn "%d %d %f %f" integer large floating single

F# has distinct numeric types: int (32-bit), int64 (64-bit, L suffix), float (64-bit double), and float32 (f suffix). Unlike Ruby, implicit coercion between them is not allowed — 1 + 1.0 is a type error.

Type conversion
puts 42.to_s puts "42".to_i puts 3.to_f
let text = string 42 let number = int "42" let decimal = float 3 printfn "%s %d %f" text number decimal

F# uses conversion functions named after the target type: string, int, float, bool, etc. Unlike Ruby's .to_s method syntax, these are plain functions. Conversions that can fail (like int "abc") raise an exception at runtime.

Boolean operations
puts true && false puts true || false puts !true puts (1 == 1) puts (1 != 2)
printfn "%b" (true && false) printfn "%b" (true || false) printfn "%b" (not true) printfn "%b" (1 = 1) printfn "%b" (1 <> 2)

F# uses &&, ||, and not (a function, not a prefix operator). Equality is =; inequality is <>. These match OCaml conventions and differ from C-family languages.

The unit type
def do_something puts "done" # Ruby methods return nil when nothing is returned explicitly end result = do_something puts result.inspect
let doSomething () = printfn "done" // Returns unit: () let result = doSomething () printfn "%A" result

F# has a unit type (written as ()) representing "no meaningful value." Functions that perform side effects and return nothing have return type unit. It is a real type — not a null or nil — and signals to callers that the function is called for its effect.

Tuples
point = [3, 4] x, y = point puts "x=#{x}, y=#{y}"
let point = (3, 4) let (x, y) = point printfn "x=%d, y=%d" x y

F# tuples use parentheses with commas: (3, 4). They are destructured with let (x, y) = .... Tuples can hold mixed types: ("Alice", 30, true) has type string * int * bool. The * in the type is read as "and."

Strings
String concatenation
first = "Hello" last = "World" puts first + ", " + last + "!"
let first = "Hello" let last = "World" printfn "%s" (first + ", " + last + "!")

F# uses + for string concatenation, the same as Ruby. However, F# does not allow mixing + with non-string types — "Count: " + 5 is a compile error. Use string 5 to convert first, or use interpolation.

String methods
text = "hello, world" puts text.length puts text.upcase puts text.include?("world") puts text.gsub("world", "F#")
let text = "hello, world" printfn "%d" text.Length printfn "%s" (text.ToUpper()) printfn "%b" (text.Contains("world")) printfn "%s" (text.Replace("world", "F#"))

F# strings are .NET strings, so all .NET string methods are available. Properties like Length use no parentheses; methods like ToUpper() do. The naming convention is PascalCase (unlike Ruby's snake_case).

Split and join
sentence = "one two three" words = sentence.split(" ") puts words.inspect puts words.join(", ")
let sentence = "one two three" let words = sentence.Split(" ") printfn "%A" words printfn "%s" (String.concat ", " words)

Split (from .NET) splits into a string array. String.concat joins a sequence of strings with a separator — the F# equivalent of Ruby's Array#join. Note that Split returns a .NET array (string[]), not an F# list.

Multiline strings
message = <<~TEXT Line one Line two Line three TEXT puts message
let message = """ Line one Line two Line three""" printfn "%s" message

F# triple-quoted strings (""") span multiple lines without escape sequences. Backslashes and quotes inside are treated literally. They are similar to Ruby heredocs but without the indentation-stripping that <<~ provides.

Trim and substring
text = " hello world " puts text.strip puts text.strip[0, 5]
let text = " hello world " let trimmed = text.Trim() let sub = trimmed.Substring(0, 5) printfn "%s" trimmed printfn "%s" sub

Trim() removes leading and trailing whitespace, equivalent to Ruby's strip. Substring(startIndex, length) extracts a portion — note that the second argument is a length, not an end index, unlike Ruby's slicing syntax.

Collections
List literal
numbers = [1, 2, 3, 4, 5] puts numbers.inspect
let numbers = [1; 2; 3; 4; 5] printfn "%A" numbers

F# lists use semicolons as element separators: [1; 2; 3]. This is the most common surprise for Rubyists. Writing [1, 2, 3] in F# creates a list of one element — a 3-tuple — not a three-element list. F# lists are immutable singly-linked lists.

List cons operator
rest = [2, 3, 4] all = [1] + rest puts all.inspect
let rest = [2; 3; 4] let all = 1 :: rest printfn "%A" all

The :: (cons) operator prepends a single element to a list. Unlike Ruby's +, cons is O(1) — it reuses the existing tail without copying it. F# lists are persistent: rest is unchanged after 1 :: rest.

List.map
numbers = [1, 2, 3, 4, 5] doubled = numbers.map { |number| number * 2 } puts doubled.inspect
let numbers = [1; 2; 3; 4; 5] let doubled = List.map (fun number -> number * 2) numbers printfn "%A" doubled

List.map is F#'s equivalent of Ruby's map. The function argument comes first, then the list. Anonymous functions use fun parameter -> body syntax — F#'s equivalent of Ruby blocks. There are no method-chaining versions; use the pipe operator instead.

List.filter
numbers = [1, 2, 3, 4, 5, 6] evens = numbers.select { |number| number.even? } puts evens.inspect
let numbers = [1; 2; 3; 4; 5; 6] let evens = List.filter (fun number -> number % 2 = 0) numbers printfn "%A" evens

List.filter is the equivalent of Ruby's select. Note that = is the equality operator (not assignment), and % is modulo. F# has no equivalent of Ruby's Integer#even? — use explicit comparison instead.

List.fold
numbers = [1, 2, 3, 4, 5] total = numbers.inject(0) { |sum, number| sum + number } puts total
let numbers = [1; 2; 3; 4; 5] let total = List.fold (fun accumulator number -> accumulator + number) 0 numbers printfn "%d" total

List.fold is the equivalent of Ruby's inject/reduce. The argument order is: function, initial value, list. List.foldBack folds from the right. The function receives the accumulator first, then the current element.

Arrays
items = [1, 2, 3] puts items[0] puts items.length
let items = [|1; 2; 3|] let first = items.[0] let count = items.Length printfn "%d %d" first count

F# arrays use [|...|] syntax with semicolons. Unlike F# lists, arrays are mutable and have O(1) indexed access. Element access uses .[index] or just [index] in newer F#. F# arrays are identical to .NET arrays.

Sequences (lazy)
evens = (1..Float::INFINITY).lazy.select { |n| n.even? }.first(5) puts evens.inspect
let allEvens = seq { for number in 1..1000 do if number % 2 = 0 then yield number } let firstFive = allEvens |> Seq.take 5 |> Seq.toList printfn "%A" firstFive

F# sequences (seq { }) are lazy, like Ruby's Enumerator::Lazy. They compute elements on demand. Seq.take 5 evaluates only the first five elements. Most F# collection functions have Seq, List, and Array variants.

Maps
ages = { "Alice" => 30, "Bob" => 25 } puts ages["Alice"] puts ages.keys.inspect
let ages = Map.ofList [("Alice", 30); ("Bob", 25)] printfn "%d" ages.["Alice"] printfn "%A" (Map.keys ages |> Seq.toList)

F# Map is an immutable ordered dictionary. It is built from a list of tuples using Map.ofList. Key-value pairs are tuples: (key, value) — not Ruby's key => value. Looking up a key that does not exist raises an exception; use Map.tryFind to get an Option instead.

Control Flow
if / elif / else
score = 85 if score >= 90 puts "A" elsif score >= 80 puts "B" else puts "C" end
let score = 85 if score >= 90 then printfn "A" elif score >= 80 then printfn "B" else printfn "C"

F# uses elif (not elsif) and requires then after each condition. Unlike Ruby, F# if is an expression — it returns a value. Both branches must have the same type, otherwise the compiler reports a type error.

if as an expression
age = 20 status = age >= 18 ? "adult" : "minor" puts status
let age = 20 let status = if age >= 18 then "adult" else "minor" printfn "%s" status

F# if/then/else is an expression that returns the value of whichever branch was taken. This replaces Ruby's ternary operator ?:. The else branch is required when the result is used — omitting it implies the value is unit.

for loop
(1..5).each { |i| puts i }
for i in 1..5 do printfn "%d" i

F# for ... in ... do iterates over ranges and sequences. 1..5 is an inclusive range, identical to Ruby's 1..5. F# also has for i = 1 to 5 do for counted loops. The body is indented — F# uses significant whitespace.

while loop
count = 1 while count <= 5 puts count count += 1 end
let mutable count = 1 while count <= 5 do printfn "%d" count count <- count + 1

F# while loops require mutable state. Idiomatic F# prefers recursion or sequence operations over imperative loops, but while is available. Note that <- is the mutation operator and there is no += shorthand in F#.

Pattern Matching
Basic match
day = "Monday" case day when "Saturday", "Sunday" puts "Weekend" else puts "Weekday" end
let day = "Monday" match day with | "Saturday" | "Sunday" -> printfn "Weekend" | _ -> printfn "Weekday"

F# match uses | for each arm and -> to separate the pattern from the body. The _ wildcard matches anything, like Ruby's else in a case. Multiple patterns on one arm use |.

Match with guards
number = 42 case number when 0 then puts "zero" when (..(-1)) then puts "negative" when (101..) then puts "large" else puts "normal" end
let number = 42 match number with | 0 -> printfn "zero" | n when n < 0 -> printfn "negative" | n when n > 100 -> printfn "large" | _ -> printfn "normal"

Match guards use when after a pattern to add a boolean condition. The bound name (n) is available in the guard expression. This is more explicit than Ruby's case/when with range patterns.

Tuple matching
point = [3, -2] case point in [x, y] if x > 0 && y > 0 then puts "Q1" in [x, y] if x < 0 && y > 0 then puts "Q2" in [x, y] if x < 0 && y < 0 then puts "Q3" else puts "Q4 or axis" end
let point = (3, -2) match point with | (x, y) when x > 0 && y > 0 -> printfn "Q1" | (x, y) when x < 0 && y > 0 -> printfn "Q2" | (x, y) when x < 0 && y < 0 -> printfn "Q3" | _ -> printfn "Q4 or axis"

F# can match on tuples by destructuring them in the pattern. Each tuple element is bound to a name and can be used in guards or the body. Tuple matching in F# is the cleanest way to handle multi-dimensional case logic.

List matching
items = [1, 2, 3] case items in [] then puts "empty" in [single] then puts "one: #{single}" in [first, *rest] then puts "head: #{first}, #{rest.length} more" end
let items = [1; 2; 3] match items with | [] -> printfn "empty" | [single] -> printfn "one: %d" single | head :: tail -> printfn "head: %d, %d more" head (List.length tail)

F# list patterns use :: to match the head and tail. [] matches empty, [x] matches a single-element list, and head :: tail matches any non-empty list. This is exhaustive — the compiler warns if a case is missing.

match returns a value
score = 85 grade = case score when 90..100 then "A" when 80..89 then "B" when 70..79 then "C" else "F" end puts 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

match is an expression in F# — it returns a value. All arms must return the same type. This allows match to appear in any expression context, including let bindings, function arguments, and inside pipelines.

Functions
Defining a function
def add(x, y) x + y end puts add(3, 4)
let add x y = x + y printfn "%d" (add 3 4)

F# function definitions use let. Parameters follow the function name separated by spaces — no parentheses, no commas, no return. The last expression is the return value. Function calls also use spaces: add 3 4, not add(3, 4).

Currying and partial application
multiply = ->(x, y) { x * y } double = multiply.curry.(2) puts double.(5)
let multiply x y = x * y let double = multiply 2 printfn "%d" (double 5)

Every F# function is automatically curried — calling it with fewer arguments than it expects returns a new function. multiply 2 returns a function int -> int. There is no .curry method — partial application is built into the language.

Function composition
double = ->(n) { n * 2 } increment = ->(n) { n + 1 } double_then_increment = double >> increment puts double_then_increment.(5)
let double number = number * 2 let increment number = number + 1 let doubleAndIncrement = double >> increment printfn "%d" (doubleAndIncrement 5)

The >> operator composes two functions left-to-right: f >> g is equivalent to fun x -> g (f x). The << operator composes right-to-left. Function composition is a first-class idiom in F# alongside the pipe operator.

Anonymous functions
numbers = [1, 2, 3, 4, 5] squared = numbers.map { |number| number * number } puts squared.inspect
let numbers = [1; 2; 3; 4; 5] let squared = List.map (fun number -> number * number) numbers printfn "%A" squared

F# anonymous functions use fun parameter -> body. Multiple parameters: fun x y -> x + y. Unlike Ruby's blocks, F# lambdas are ordinary values — they can be stored in let bindings, passed to functions, and returned from functions.

Recursive functions
def factorial(n) return 1 if n <= 1 n * factorial(n - 1) end puts factorial(10)
let rec factorial n = if n <= 1 then 1 else n * factorial (n - 1) printfn "%d" (factorial 10)

F# requires the rec keyword to define a recursive function. This makes recursion explicit — a function without rec cannot call itself. The compiler can optimize tail-recursive functions to avoid stack overflow.

Higher-order functions
def apply_twice(func, value) func.call(func.call(value)) end double = ->(n) { n * 2 } puts apply_twice(double, 3)
let applyTwice func value = func (func value) let double number = number * 2 printfn "%d" (applyTwice double 3)

Functions are first-class values in F#. A function that accepts another function as a parameter is a higher-order function. The applyTwice function has inferred type ('a -> 'a) -> 'a -> 'a — it works for any type, not just integers.

Pipe Operator
Basic pipe operator
result = [1, 2, 3, 4, 5, 6] .select { |n| n.even? } .map { |n| n * 3 } .sum puts result
let result = [1; 2; 3; 4; 5; 6] |> List.filter (fun number -> number % 2 = 0) |> List.map (fun number -> number * 3) |> List.sum printfn "%d" result

The |> pipe operator passes the value on the left as the last argument to the function on the right. It reads top-to-bottom like Ruby's method chaining, but works with any function — not just methods on an object.

Pipes vs nested calls
words = ["hello", "world", "fsharp"] # Method chaining reads left to right: result = words.map(&:upcase).select { |w| w.length > 5 }.first puts result
let words = ["hello"; "world"; "fsharp"] // With pipes (reads in execution order): let withPipes = words |> List.map (fun word -> word.ToUpper()) |> List.filter (fun word -> word.Length > 5) |> List.head // Equivalent nested form (reads inside-out): let nested = List.head (List.filter (fun (word: string) -> word.Length > 5) (List.map (fun (word: string) -> word.ToUpper()) words)) printfn "%s" withPipes

Without pipes, nested function calls must be read inside-out, which obscures the order of operations. Pipes make the data flow explicit and match the direction you read. This is the primary reason |> is so central to idiomatic F#.

Pipes with custom functions
def add_tax(price) price * 1.1 end def round_to_cents(amount) (amount * 100).round / 100.0 end total = 29.99 puts round_to_cents(add_tax(total))
let addTax price = price * 1.1 let roundToCents amount = System.Math.Round(amount * 100.0) / 100.0 let total = 29.99 let result = total |> addTax |> roundToCents printfn "%f" result

The pipe operator works with any user-defined function, not just library functions. total |> addTax |> roundToCents is equivalent to roundToCents (addTax total). This style encourages designing functions that accept their primary data as the last argument.

Pipe vs composition
# Ruby has no direct equivalent to >> # These are both possible: triple = ->(n) { n * 3 } add_one = ->(n) { n + 1 } # Composition: triple_add = triple >> add_one puts triple_add.(4) # Chaining (requires a value): puts add_one.(triple.(4))
let triple number = number * 3 let addOne number = number + 1 // >> composes functions (no value needed yet): let tripleAndAdd = triple >> addOne printfn "%d" (tripleAndAdd 4) // |> applies to a specific value: let result = 4 |> triple |> addOne printfn "%d" result

>> composes two functions into a new function without a value — it is for defining reusable pipelines. |> applies functions to a specific value — it is for data transformation. Both produce the same result when given the same input; the choice depends on whether you need the composed function as a reusable value.

Option & Result
Option type
def find_user(id) users = { 1 => "Alice", 2 => "Bob" } users[id] # returns nil if not found end user = find_user(1) puts user.nil? ? "not found" : "found: #{user}"
let findUser identifier = let users = Map.ofList [(1, "Alice"); (2, "Bob")] Map.tryFind identifier users // returns Some "Alice" or None match findUser 1 with | Some name -> printfn "found: %s" name | None -> printfn "not found"

F# uses Some value and None instead of value-or-nil. Map.tryFind returns Some value on success and None on failure. The type system enforces handling both cases — Option<string> cannot be used where a string is expected.

Option.map
# Ruby uses &. (safe navigation) to map over nil-or-value name = "Alice" length = name&.length puts length
let name = Some "Alice" let length = Option.map (fun (text: string) -> text.Length) name printfn "%A" length

Option.map applies a function to the value inside Some, leaving None unchanged. It is the typed equivalent of Ruby's &. safe navigation operator. The result is always an OptionSome "Alice" becomes Some 5.

Option.bind
def parse_int(text) Integer(text) rescue nil end def double_if_positive(n) n > 0 ? n * 2 : nil end result = parse_int("5")&.then { |n| double_if_positive(n) } puts result
let tryParseInt (text: string) = match System.Int32.TryParse(text) with | (true, value) -> Some value | _ -> None let doubleIfPositive number = if number > 0 then Some (number * 2) else None let result = tryParseInt "5" |> Option.bind doubleIfPositive printfn "%A" result

Option.bind chains operations that each return an Option. If any step returns None, the chain short-circuits. This is the typed equivalent of Ruby's &.then { |x| ... } safe navigation chain, but tracked by the type system.

Result type
def divide(numerator, denominator) return [:error, "division by zero"] if denominator == 0 [:ok, numerator / denominator] end case divide(10, 2) in [:ok, value] then puts "Result: #{value}" in [:error, msg] then puts "Error: #{msg}" end
let divide numerator denominator = if denominator = 0 then Error "division by zero" else Ok (numerator / denominator) match divide 10 2 with | Ok value -> printfn "Result: %d" value | Error message -> printfn "Error: %s" message

F# has a built-in Result<'T, 'E> type with Ok value and Error reason. It encodes expected failure in the type system, unlike Ruby's convention-based [:ok, value] tuples. The type forces callers to handle both outcomes.

Result.map
def parse_number(text) [true, Integer(text)] rescue ArgumentError [false, "not a number: #{text}"] end success, value = parse_number("42") puts success ? value * 2 : value
let tryParseNumber (text: string) = match System.Int32.TryParse(text) with | (true, value) -> Ok value | _ -> Error $"not a number: {text}" let result = tryParseNumber "42" |> Result.map (fun number -> number * 2) printfn "%A" result

Result.map applies a function to the value inside Ok, leaving Error unchanged. Combined with the pipe operator, it enables a chain of transformations that propagates the first error and continues on success.

Records
Defining a record
Person = Struct.new(:name, :age) person = Person.new("Alice", 30) puts person.name puts person.age
type Person = { Name: string; Age: int } let person = { Name = "Alice"; Age = 30 } printfn "%s is %d" person.Name person.Age

F# records are immutable named data structures. Fields use FieldName: Type syntax. Record creation uses { FieldName = value; ... }. They are like Ruby's Struct but immutable by default and fully integrated with pattern matching and the type system.

Record copy-and-update
person = { name: "Alice", age: 30 } older = person.merge(age: 31) puts older
type Person = { Name: string; Age: int } let alice = { Name = "Alice"; Age = 30 } let olderAlice = { alice with Age = 31 } printfn "%s is %d" olderAlice.Name olderAlice.Age

The with keyword creates a copy of a record with specified fields changed. The original record is unchanged. This is the idiomatic way to "update" an immutable record — it is the F# equivalent of Ruby's Hash#merge for creating modified copies.

Matching on records
Person = Struct.new(:name, :age) person = Person.new("Alice", 30) case person in { name:, age: (..17) } then puts "#{name} is a minor" in { name:, age: } then puts "#{name} is an adult (#{age})" end
type Person = { Name: string; Age: int } let person = { Name = "Alice"; Age = 30 } match person with | { Name = name; Age = age } when age < 18 -> printfn "%s is a minor" name | { Name = name; Age = age } -> printfn "%s is an adult (%d)" name age

Records can be matched by field name. The { FieldName = binding } pattern extracts field values into local names. Partial matching is allowed — unmentioned fields are ignored. This is more structured than Ruby's hash pattern matching.

Nested records
Address = Struct.new(:city, :country) Person = Struct.new(:name, :address) person = Person.new("Alice", Address.new("Paris", "France")) puts person.address.city
type Address = { City: string; Country: string } type Person = { Name: string; Address: Address } let person = { Name = "Alice"; Address = { City = "Paris"; Country = "France" } } printfn "%s" person.Address.City

Records can nest other records. The with copy-update syntax handles nested updates, though it requires updating each level explicitly: { person with Address = { person.Address with City = "Berlin" } }. Lenses and optics libraries provide more ergonomic nested updates.

Discriminated Unions
Simple discriminated union
# Ruby uses symbols or constants for sum types module Color RED = :red GREEN = :green BLUE = :blue end color = Color::RED puts color
type Color = Red | Green | Blue let color = Red printfn "%A" color

Discriminated unions (DUs) are F#'s algebraic sum types — a value is exactly one of a fixed set of cases. Each case is a named constructor. Pattern matching on a DU is exhaustive: the compiler warns if you miss a case. Ruby has no direct equivalent; symbols or classes are the closest approximation.

DU cases with data
class Shape Circle = Struct.new(:radius) Rectangle = Struct.new(:width, :height) end shape = Shape::Circle.new(5.0) area = case shape when Shape::Circle then Math::PI * shape.radius ** 2 when Shape::Rectangle then shape.width * shape.height end puts area.round(2)
type Shape = | Circle of radius: float | Rectangle of width: float * height: float let shape = Circle(radius = 5.0) let area = match shape with | Circle(radius) -> System.Math.PI * radius * radius | Rectangle(width, height) -> width * height printfn "%.2f" area

DU cases carry typed data. Circle of radius: float means the Circle case holds a float value named radius. Naming the fields makes construction and pattern matching self-documenting. This replaces Ruby's class hierarchy with a single concise type declaration.

Recursive DU (tree)
# A simple binary tree in Ruby using classes class Tree Leaf = Class.new Node = Struct.new(:value, :left, :right) end tree = Tree::Node.new(1, Tree::Node.new(2, Tree::Leaf.new, Tree::Leaf.new), Tree::Leaf.new) puts tree.value
type Tree = | Leaf | Node of value: int * left: Tree * right: Tree let tree = Node(value = 1, left = Node(value = 2, left = Leaf, right = Leaf), right = Leaf) let rec depth tree = match tree with | Leaf -> 0 | Node(_, left, right) -> 1 + max (depth left) (depth right) printfn "depth: %d" (depth tree)

Discriminated unions can be recursive — a Tree case can contain Tree values. Recursive DUs are the natural way to represent trees, linked lists, expression trees, and ASTs in F#. The rec keyword is needed for functions that recurse over them.

Option is a DU
# Ruby's nil and non-nil are implicit; F# makes it explicit value = 42 # Some(42) missing = nil # None puts missing.nil?
// Option<'T> is defined as: // type Option<'T> = Some of 'T | None let present: int option = Some 42 let absent: int option = None printfn "%A" present printfn "%A" absent printfn "%b" (Option.isNone absent)

Option<'T> is itself a discriminated union built into F#. The 'T is a generic type parameter. Understanding DUs explains why Some and None behave as they do — they are just case constructors of an ordinary DU.

Classes & OOP
Defining a class
class Animal def initialize(name, sound) @name = name @sound = sound end def speak puts "#{@name} says #{@sound}" end end dog = Animal.new("Dog", "woof") dog.speak
type Animal(name: string, sound: string) = member _.Speak() = printfn "%s says %s" name sound let dog = Animal("Dog", "woof") dog.Speak()

F# classes use a primary constructor directly in the type definition — constructor parameters are listed after the type name. member _.Method() defines an instance method; _ is the self reference (often written this). Constructor parameters are automatically in scope for all members.

Properties
class Person attr_reader :name attr_accessor :age def initialize(name, age) @name = name @age = age end end person = Person.new("Alice", 30) puts person.name person.age = 31 puts person.age
type Person(name: string, initialAge: int) = let mutable age = initialAge member _.Name = name member _.Age with get() = age and set(value) = age <- value let person = Person("Alice", 30) printfn "%s" person.Name person.Age <- 31 printfn "%d" person.Age

F# properties are defined with member. Read-only properties use member _.Name = value. Mutable properties require explicit get() and set(value) accessors. F# code typically prefers immutable records over mutable classes.

Inheritance
class Vehicle def initialize(make) @make = make end def describe = puts "Vehicle: #{@make}" end class Car < Vehicle def initialize(make, model) super(make) @model = model end def describe = puts "Car: #{@make} #{@model}" end Car.new("Toyota", "Camry").describe
type Vehicle(make: string) = abstract member Describe: unit -> unit default _.Describe() = printfn "Vehicle: %s" make type Car(make: string, model: string) = inherit Vehicle(make) override _.Describe() = printfn "Car: %s %s" make model let car = Car("Toyota", "Camry") car.Describe()

F# uses inherit for class inheritance. To override a method, the base class must declare it abstract with a default implementation. Derived classes use override. F# enforces this distinction explicitly — unlike Ruby, where any method can be overridden.

Interfaces
module Describable def describe raise NotImplementedError, "#{self.class} must implement describe" end end class Product include Describable def initialize(name, price) @name, @price = name, price end def describe = puts "#{@name}: $#{'%.2f' % @price}" end Product.new("Widget", 9.99).describe
type IDescribable = abstract member Describe: unit -> unit type Product(name: string, price: float) = interface IDescribable with member _.Describe() = printfn "%s: $%.2f" name price let product = Product("Widget", 9.99) (product :> IDescribable).Describe()

F# interfaces use abstract member declarations. Implementing them requires an explicit interface InterfaceName with block. Casting to an interface uses :> (upcast). F# modules and functions often replace interfaces for simpler cases.

Computation Expressions
List comprehension
pairs = (1..3).flat_map do |x| (1..3).map { |y| [x, y] } end.reject { |(x, y)| x == y } puts pairs.inspect
let pairs = [ for x in 1..3 do for y in 1..3 do if x <> y then yield (x, y) ] printfn "%A" pairs

F# list comprehensions use [ for ... do ... yield ... ]. Multiple for loops create the cartesian product. <> is the not-equal operator. The yield keyword produces each element. This is more readable than Ruby's nested flat_map.

Sequence expression
fibonacci = Enumerator.new do |yielder| a, b = 0, 1 loop do yielder << a a, b = b, a + b end end puts fibonacci.take(8).inspect
let fibonacci = seq { let mutable a = 0 let mutable b = 1 while true do yield a let next = a + b a <- b b <- next } printfn "%A" (Seq.take 8 fibonacci |> Seq.toList)

F# seq { } computation expressions create lazy sequences evaluated on demand, like Ruby's Enumerator. yield produces each value. Only as many elements as needed are computed — Seq.take 8 evaluates exactly 8 steps of the loop.

Async expression
# Ruby uses threads or fibers for async patterns result = Thread.new { 42 * 2 }.value puts result
let asyncComputation = async { let value = 42 return value * 2 } let result = Async.RunSynchronously asyncComputation printfn "%d" result

F# async { } computation expressions define asynchronous workflows. let! binds the result of another async operation without blocking. Async.RunSynchronously runs the workflow to completion. F#'s native async predates C#'s async/await and served as its inspiration.

Option computation expression
def try_parse(text) Integer(text) rescue nil end def safe_divide(numerator, denominator) denominator == 0 ? nil : numerator / denominator end result = try_parse("10")&.then { |n| safe_divide(n, 2) } puts result
// Using Option.bind to chain Option-returning functions: let tryParse (text: string) = match System.Int32.TryParse(text) with | (true, value) -> Some value | _ -> None let safeDivide numerator denominator = if denominator = 0 then None else Some (numerator / denominator) let result = tryParse "10" |> Option.bind (fun number -> safeDivide number 2) printfn "%A" result

Option.bind chains computations that return Option. When any step returns None, the entire chain short-circuits to None. This is equivalent to Ruby's safe navigation chain (&.then) but enforced by the type system at compile time.

Modules
Module definition
module MathUtils def self.square(number) number * number end def self.cube(number) number * number * number end end puts MathUtils.square(4) puts MathUtils.cube(3)
module MathUtils = let square number = number * number let cube number = number * number * number printfn "%d" (MathUtils.square 4) printfn "%d" (MathUtils.cube 3)

F# modules are namespaces for functions and types. They are defined with module ModuleName = followed by indented definitions. Module functions are called as ModuleName.functionName arg, equivalent to Ruby's module methods defined with self..

Opening a module
module Greeter def self.greet(name) puts "Hello, #{name}!" end end include Greeter rescue nil Greeter.greet("Alice")
module Greeter = let greet name = printfn "Hello, %s!" name open Greeter greet "Alice"

open ModuleName makes all of a module's definitions available without qualification. This is like Ruby's include, but for functions rather than instance methods. Opening List, Seq, or Map is a common pattern in F# code that uses these modules heavily.

Module-level execution
# In Ruby, top-level code runs directly as a script name = "Alice" puts "Hello, #{name}!"
// F# top-level code (outside any module) runs as the entry point: let name = "Alice" printfn "Hello, %s!" name

F# top-level let bindings and expressions outside any module act as the program's entry point — no main function needed. This is similar to Ruby's script mode. Larger F# programs organize code into modules and use [<EntryPoint>] for the main function.

Error Handling
try / with
begin result = 10 / 0 puts result rescue ZeroDivisionError => error puts "Error: #{error.message}" end
try let result = 10 / 0 printfn "%d" result with | :? System.DivideByZeroException as error -> printfn "Error: %s" error.Message

F# uses try/with for exception handling. The :? operator performs a type test: :? ExceptionType as name is like Ruby's rescue ExceptionClass => variable. Multiple exception types can be matched with separate | arms.

try / finally
begin puts "doing work" ensure puts "cleanup always runs" end
try printfn "doing work" finally printfn "cleanup always runs"

try/finally ensures the cleanup block runs whether or not an exception occurred — the equivalent of Ruby's ensure. In F#, try/with and try/finally cannot be combined in one expression; nest them when both are needed.

Raising exceptions
def validate_age(age) raise ArgumentError, "Age must be positive" if age < 0 age end begin validate_age(-5) rescue ArgumentError => error puts error.message end
let validateAge age = if age < 0 then raise (System.ArgumentException("Age must be positive")) age try validateAge -5 |> ignore with | :? System.ArgumentException as error -> printfn "%s" error.Message

raise throws an exception in F#. .NET exception types are used — System.ArgumentException, System.InvalidOperationException, etc. The |> ignore discards a return value when only the side effect (the potential exception) is needed.

Result vs exceptions
def parse_age(text) age = Integer(text) raise ArgumentError, "Age must be non-negative" if age < 0 age rescue ArgumentError => error puts "Error: #{error.message}" nil end puts parse_age("thirty").inspect
let tryParseAge (text: string) = match System.Int32.TryParse(text) with | (true, age) when age >= 0 -> Ok age | (true, _) -> Error "Age must be non-negative" | _ -> Error $"'{text}' is not a valid number" match tryParseAge "thirty" with | Ok age -> printfn "age is %d" age | Error message -> printfn "Error: %s" message

Idiomatic F# prefers Result<'T, 'E> over exceptions for expected failure paths — parsing, validation, and lookups. Exceptions are reserved for truly unexpected failures. Using Result makes the function's failure modes explicit in its type signature and forces callers to handle them.