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.
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 "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.
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.
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.
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.
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#.
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.
# 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.
# 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.
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.
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.
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.
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.
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."
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
(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.
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#.
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 |.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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#.
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.
# 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.
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.
# 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 Option — Some "Alice" becomes Some 5.
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.
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.
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.
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.
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.
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.
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.
# 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.
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.
# 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.
# 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.
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.
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.
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.
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.
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.
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.
# 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.
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.
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..
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.
# 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.
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.
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.
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.
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.