Basics & Output
Hello, World
Elm has no print statement — a program is a pure
main value the runtime renders. F# is the first big unlearning: it is an ML dialect with side effects anywhere, so a program is a file of statements executed top to bottom.module Main exposing (main)
import Html exposing (text)
main =
text "Hello, World!"
printfn "Hello, World!" The syntax will feel like home — F# and Elm are both children of ML. But
printfn is an ordinary side effect performed mid-program, something Elm forbids by construction. F# leaves that discipline to you.Printing any value
Elm inspects a value with
Debug.toString. F# format strings do the same with %A — and the compiler type-checks every format specifier against its argument.module Main exposing (main)
import Html exposing (text)
main =
text (Debug.toString [ 1, 2, 3 ])
printfn "%A" [ 1; 2; 3 ]
printfn "%d and %s" 42 "text" The specifiers are typed:
%d demands an integer, %s a string, and %A pretty-prints anything (like Debug.toString). Passing the wrong type is a compile error, not a runtime surprise — the same promise Elm makes, kept by different machinery.Comments
module Main exposing (main)
import Html exposing (text)
-- a single-line comment
{- a block
comment -}
main =
text "done"
// a single-line comment
(* a block
comment *)
printfn "done" F# supports C-style
// line comments and ML-style (* … *) block comments — the latter is the same block-comment family as Elm's {- -}, and both nest properly.Values & Mutation
Bindings & type inference
Both languages infer types and let you annotate when you want the documentation. Elm annotates on a separate line; F# annotates inline after a colon.
module Main exposing (main)
import Html exposing (text)
greeting : String
greeting =
"Hello from Elm"
answer =
42
main =
text (greeting ++ " " ++ String.fromInt answer)
let greeting: string = "Hello from F#"
let answer = 42
printfn "%s %d" greeting answer Hindley–Milner inference runs the show in both languages:
answer needs no annotation in either. Note the case convention flip — Elm writes String, F# writes .NET's string in lowercase.Immutable by default — until you ask
Elm values are immutable, period. F# values are immutable by default, but the
mutable keyword and the <- assignment operator opt out.module Main exposing (main)
import Html exposing (text)
counter : Int
counter =
0
incremented : Int
incremented =
counter + 1
main =
text (String.fromInt incremented)
let mutable counter = 0
counter <- counter + 1
counter <- counter + 1
printfn "%d" counter In Elm a "changed" value is always a new name (or a new record); there is no way to write
counter twice. F# keeps immutability as the default — plain let cannot be reassigned — but hands you an escape hatch. Idiomatic F# uses mutable sparingly, and your Elm instincts are exactly the right ones to bring.Numbers & Strings
Division: two operators vs one
Elm gives you
/ for floats and // for integers. F# has one / whose meaning depends on its operand types — 7 / 2 is integer division.module Main exposing (main)
import Html exposing (text)
main =
text (String.fromInt (7 // 2) ++ " and " ++ String.fromFloat (7 / 2))
printfn "%d" (7 / 2)
printfn "%f" (float 7 / float 2) This is the classic F# gotcha for Elm developers:
7 / 2 is 3, because both operands are integers. There is no implicit numeric conversion (a restraint Elm shares), so you convert explicitly with float to get 3.5.Concatenation & interpolation
Elm famously has no string interpolation — everything is
++. F# has both: + concatenates, and $"…" interpolates any expression in braces.module Main exposing (main)
import Html exposing (text)
name : String
name =
"world"
main =
text ("Hello, " ++ name ++ "! " ++ String.fromInt (6 * 7))
let name = "world"
printfn "%s" ("Hello, " + name + "!")
printfn "%s" $"Hello, {name}! {6 * 7}" The interpolated form
$"Hello, {name}!" is idiomatic modern F#, and unlike Elm's ++ chains it never needs a String.fromInt — the braces accept any expression and format it. Note F# concatenates strings with +, which Elm deliberately rejected in favor of a dedicated ++.String functions: module vs method
Elm strings are worked on with
String.* functions. F# gives you both worlds: an ML-style String module and .NET instance methods on the string itself.module Main exposing (main)
import Html exposing (text)
greeting : String
greeting =
"hello world"
main =
text (String.toUpper greeting ++ " / " ++ String.fromInt (String.length greeting))
let greeting = "hello world"
printfn "%s" (greeting.ToUpper())
printfn "%d" (String.length greeting)
printfn "%s" (greeting.Replace("world", "F#")) The
String.length form pipelines nicely and will feel like Elm; the .ToUpper() form is a .NET method call on the object. Idiomatic F# mixes them — module functions in pipelines, methods when the .NET library has no functional wrapper.Functions & Currying
Definitions & partial application
Both languages curry every function automatically, so partial application is just calling with fewer arguments. The definitions are nearly the same line.
module Main exposing (main)
import Html exposing (text)
add : Int -> Int -> Int
add first second =
first + second
addTen : Int -> Int
addTen =
add 10
main =
text (String.fromInt (addTen 5))
let add first second = first + second
let addTen = add 10
printfn "%d" (addTen 5) This is the deepest bridge between the two languages:
add 10 returns a new function in both, no lambda required. Even the inferred type is written the same way — hover an F# add and you will see int -> int -> int, exactly Elm's arrow signature.Anonymous functions
module Main exposing (main)
import Html exposing (text)
main =
text (Debug.toString (List.map (\number -> number * 3) [ 1, 2, 3 ]))
printfn "%A" (List.map (fun number -> number * 3) [ 1; 2; 3 ]) Elm's backslash lambda
\number -> becomes the spelled-out fun number ->. Same shape, same arrow — F# just writes the ML keyword that Elm abbreviated.Function composition
The composition operator
>> is not just similar — it is the same operator, gluing small functions into a pipeline-shaped value in both languages.module Main exposing (main)
import Html exposing (text)
double : Int -> Int
double number =
number * 2
increment : Int -> Int
increment number =
number + 1
doubleThenIncrement : Int -> Int
doubleThenIncrement =
double >> increment
main =
text (String.fromInt (doubleThenIncrement 5))
let double number = number * 2
let increment number = number + 1
let doubleThenIncrement = double >> increment
printfn "%d" (doubleThenIncrement 5) F# also has
<< for right-to-left composition, exactly like Elm. Elm inherited both operators (and their spelling) from F#.Recursion needs `rec`
Elm functions can always call themselves. In F#, a binding is not in scope inside its own body unless you say
let rec.module Main exposing (main)
import Html exposing (text)
factorial : Int -> Int
factorial number =
if number <= 1 then
1
else
number * factorial (number - 1)
main =
text (String.fromInt (factorial 5))
let rec factorial number =
if number <= 1 then 1
else number * factorial (number - 1)
printfn "%d" (factorial 5) The
rec keyword is classic ML bookkeeping: it tells the compiler the name being defined may appear in its own definition. Forgetting it is a friendly compile error, not a trap. Note that F#'s if … then … else is an expression, exactly like Elm's.The Pipe Operator
The pipe came from here
Elm's beloved
|> was borrowed from F#. It means exactly the same thing, so your pipeline habits transfer without translation.module Main exposing (main)
import Html exposing (text)
main =
[ 1, 2, 3, 4, 5, 6 ]
|> List.filter (\number -> modBy 2 number == 0)
|> List.map (\number -> number * number)
|> List.sum
|> String.fromInt
|> text
[ 1; 2; 3; 4; 5; 6 ]
|> List.filter (fun number -> number % 2 = 0)
|> List.map (fun number -> number * number)
|> List.sum
|> printfn "%d" Same operator, same left-to-right story, and mostly the same
List vocabulary on both sides of it. Two small dialect notes: F# tests equality with a single = (not ==), and takes modulo with the % operator rather than a modBy function.Collections
Lists: commas become semicolons
F# list literals separate elements with semicolons. Writing Elm-style commas is the most common F# beginner trap — it silently builds a one-element list containing a tuple.
module Main exposing (main)
import Html exposing (text)
numbers : List Int
numbers =
[ 1, 2, 3 ]
main =
text (Debug.toString numbers)
let numbers = [ 1; 2; 3 ]
printfn "%A" numbers
// The Elm habit compiles — as ONE tuple in a list:
let surprise = [ 1, 2, 3 ]
printfn "%A" surprise In F#,
, builds tuples and ; separates list elements, so [ 1, 2, 3 ] is [(1, 2, 3)] — a one-element list holding a triple. It type-checks, which makes it sneakier than a syntax error. Both languages' lists are immutable singly-linked lists.The List module, twin edition
module Main exposing (main)
import Html exposing (text)
main =
[ 1, 2, 3, 4 ]
|> List.map (\number -> number * 10)
|> List.filter (\number -> number > 15)
|> Debug.toString
|> text
[ 1; 2; 3; 4 ]
|> List.map (fun number -> number * 10)
|> List.filter (fun number -> number > 15)
|> printfn "%A" The core vocabulary is shared:
List.map, List.filter, List.sum, List.length, List.rev. Elm's List.foldl answers to List.fold in F#. When you need laziness or arrays, F# also offers the same functions under Seq and Array.Cons and ranges
The cons operator
:: is identical. F# adds range literals — [ 1 .. 5 ] — which Elm spells as a function call.module Main exposing (main)
import Html exposing (text)
main =
text (Debug.toString (1 :: [ 2, 3 ]) ++ " " ++ Debug.toString (List.range 1 5))
printfn "%A" (1 :: [ 2; 3 ])
printfn "%A" [ 1 .. 5 ]
printfn "%A" [ 0 .. 2 .. 10 ] The three-part range
[ 0 .. 2 .. 10 ] counts by a step — [0; 2; 4; 6; 8; 10] — something Elm needs List.range plus a List.map to express. Cons patterns like head :: tail work in match exactly as they do in Elm's case.Tuples
module Main exposing (main)
import Html exposing (text)
point : ( Int, String )
point =
( 1, "one" )
main =
text (String.fromInt (Tuple.first point) ++ " is " ++ Tuple.second point)
let point = (1, "one")
let (number, name) = point
printfn "%d is %s" number name
printfn "%d" (fst point) F# writes the tuple type as
int * string — the ML "product type" star — where Elm writes ( Int, String ). Destructuring in a let works in both languages; F#'s fst/snd are Elm's Tuple.first/Tuple.second.Arrays are a different animal
Elm's
Array is an immutable persistent structure. F#'s [| … |] array is the .NET array — fixed-length, contiguous, and mutable in place.module Main exposing (main)
import Html exposing (text)
main =
text (Debug.toString [ 10, 20, 30 ])
let numbers = [| 10; 20; 30 |]
numbers[0] <- 99
printfn "%A" numbers
printfn "%d" numbers[1] The banded brackets
[| |] mark a real mutable array: numbers[0] <- 99 overwrites the slot, no new array allocated. There is no Elm equivalent to in-place update — an Elm Array.set returns a new array. Reach for F# arrays when interoperating with .NET or when performance demands it; stay with lists for everyday code.Records
Record definition & access
Both languages center on records with named fields. Elm's
type alias becomes a nominal type definition in F#, and field names conventionally start uppercase.module Main exposing (main)
import Html exposing (text)
type alias Person =
{ name : String
, age : Int
}
person : Person
person =
{ name = "Ada", age = 36 }
main =
text (person.name ++ " is " ++ String.fromInt person.age)
type Person = { Name: string; Age: int }
let person = { Name = "Ada"; Age = 36 }
printfn "%s is %d" person.Name person.Age One conceptual difference hides here: F# records are nominal —
{ Name = "Ada"; Age = 36 } is inferred to be a Person because a record type with exactly those fields is in scope. Elm records are structural and need no declaration at all. In practice both feel the same: define the shape, build with a literal, access with a dot.Record update
Elm's
{ person | age = 37 } is F#'s { person with Age = 37 } — same copy-with-changes semantics, one keyword apart.module Main exposing (main)
import Html exposing (text)
type alias Person =
{ name : String
, age : Int
}
person : Person
person =
{ name = "Ada", age = 36 }
older : Person
older =
{ person | age = 37 }
main =
text (older.name ++ " is now " ++ String.fromInt older.age)
type Person = { Name: string; Age: int }
let person = { Name = "Ada"; Age = 36 }
let older = { person with Age = 37 }
printfn "%s is now %d" older.Name older.Age
printfn "%s is still %d" person.Name person.Age Both expressions build a new record sharing the unchanged fields; the original is untouched, as the second
printfn shows. Your Elm mental model transfers wholesale — only the pipe character becomes the word with.Custom Types & Unions
Custom types are discriminated unions
Elm's custom types are called discriminated unions in F#. The payload is introduced with
of, and multi-value payloads are tuples joined by *.module Main exposing (main)
import Html exposing (text)
type Shape
= Circle Float
| Rectangle Float Float
area : Shape -> Float
area shape =
case shape of
Circle radius ->
3.14159 * radius * radius
Rectangle width height ->
width * height
main =
text (String.fromFloat (area (Rectangle 3 4)))
type Shape =
| Circle of float
| Rectangle of float * float
let area shape =
match shape with
| Circle radius -> 3.14159 * radius * radius
| Rectangle (width, height) -> width * height
printfn "%f" (area (Rectangle (3.0, 4.0))) Same idea, same exhaustiveness checking, slightly different plumbing: an F# constructor with several values carries one tuple (
Rectangle (3.0, 4.0)), where Elm's Rectangle 3 4 is curried. The match pattern therefore also wears tuple parentheses.Generic type parameters
Elm's lowercase type variables (
Tree a) become tick-marked parameters in angle brackets (Tree<'value>).module Main exposing (main)
import Html exposing (text)
type Tree a
= Leaf
| Node (Tree a) a (Tree a)
size : Tree a -> Int
size tree =
case tree of
Leaf ->
0
Node left _ right ->
1 + size left + size right
main =
text (String.fromInt (size (Node (Node Leaf 1 Leaf) 2 (Node Leaf 3 Leaf))))
type Tree<'value> =
| Leaf
| Node of Tree<'value> * 'value * Tree<'value>
let rec size tree =
match tree with
| Leaf -> 0
| Node (left, _, right) -> 1 + size left + size right
printfn "%d" (size (Node (Node (Leaf, 1, Leaf), 2, Node (Leaf, 3, Leaf)))) The tick (
'value) is ML's traditional type-variable marker — Elm simply dropped it. Option<'a>, Result<'ok, 'error>, and List<'a> all follow this shape, and F# infers the parameters just as Elm does.Pattern Matching
case…of becomes match…with
module Main exposing (main)
import Html exposing (text)
describe : List Int -> String
describe numbers =
case numbers of
[] ->
"empty"
[ single ] ->
"one: " ++ String.fromInt single
first :: _ ->
"starts with " ++ String.fromInt first
main =
text (describe [ 7, 8, 9 ])
let describe numbers =
match numbers with
| [] -> "empty"
| [ single ] -> $"one: {single}"
| first :: _ -> $"starts with {first}"
printfn "%s" (describe [ 7; 8; 9 ]) Everything transfers: the empty-list pattern, the exact-length bracket pattern, cons with
::, and the wildcard _. F# marks each branch with a leading | instead of Elm's indentation, and warns (rather than errors) on a non-exhaustive match — turn on warn-as-error to restore Elm's strictness.Guards: `when` clauses
Elm deliberately has no pattern guards — you nest
if expressions instead. F# lets a pattern carry a when condition.module Main exposing (main)
import Html exposing (text)
describe : Int -> String
describe number =
if number == 0 then
"zero"
else if number < 0 then
"negative"
else if modBy 2 number == 0 then
"positive even"
else
"positive odd"
main =
text (describe (-4))
let describe number =
match number with
| 0 -> "zero"
| value when value < 0 -> "negative"
| value when value % 2 = 0 -> "positive even"
| _ -> "positive odd"
printfn "%s" (describe (-4)) A
when guard runs an arbitrary boolean after the pattern binds its names. It is expressive, but note the cost Elm avoided by omitting them: the compiler cannot prove guarded matches exhaustive, which is why the final _ branch is required here.Option, Result & Exceptions
Maybe is Option
Maybe a is Option<'a>: Just becomes Some, Nothing becomes None, and the module functions map almost one to one.module Main exposing (main)
import Html exposing (text)
firstEven : Maybe Int
firstEven =
List.filter (\number -> modBy 2 number == 0) [ 1, 3, 4, 6 ]
|> List.head
main =
firstEven
|> Maybe.map (\number -> number * 10)
|> Maybe.withDefault 0
|> String.fromInt
|> text
let firstEven =
[ 1; 3; 4; 6 ] |> List.tryFind (fun number -> number % 2 = 0)
firstEven
|> Option.map (fun number -> number * 10)
|> Option.defaultValue 0
|> printfn "%d" Maybe.map is Option.map, Maybe.withDefault is Option.defaultValue, and Maybe.andThen is Option.bind. F#'s partial lookups are conventionally named tryFind, tryHead, tryParse — the try prefix signals an Option return, just as Elm's List.head signals a Maybe by its type.Result, same but flipped
Both languages ship a
Result. One arrangement flip: Elm writes Result error value; F# writes Result<'ok, 'error> with the success type first.module Main exposing (main)
import Html exposing (text)
safeDivide : Float -> Float -> Result String Float
safeDivide numerator denominator =
if denominator == 0 then
Err "cannot divide by zero"
else
Ok (numerator / denominator)
main =
case safeDivide 10 2 of
Ok quotient ->
text (String.fromFloat quotient)
Err reason ->
text reason
let safeDivide numerator denominator =
if denominator = 0.0 then Error "cannot divide by zero"
else Ok (numerator / denominator)
match safeDivide 10.0 2.0 with
| Ok quotient -> printfn "%f" quotient
| Error reason -> printfn "%s" reason Elm's
Err constructor is spelled out as Error in F#, and Result.map/Result.bind chain failures through a pipeline exactly like Result.map/Result.andThen do in Elm.Exceptions exist here
Elm removed exceptions from the language; failure is always a value. F# has real .NET exceptions —
failwith throws, try … with catches — living alongside Result.module Main exposing (main)
import Html exposing (text)
-- Elm's only failure channel is a value.
parsed : Result String Int
parsed =
String.toInt "not a number"
|> Result.fromMaybe "parse failed"
main =
text (Debug.toString parsed)
let riskyDivide numerator denominator =
if denominator = 0 then failwith "cannot divide by zero"
else numerator / denominator
try
printfn "%d" (riskyDivide 10 0)
with exceptionValue ->
printfn "caught: %s" exceptionValue.Message This is the second big unlearning after side effects. Much of the .NET library throws rather than returning
Option/Result, so F# code near the platform boundary needs try … with. Idiomatic F# converts exceptions into Result at that boundary and stays Elm-shaped in the core — a discipline the compiler will not enforce for you.Units of Measure
Types for physics
Elm developers simulate unit safety with wrapper types. F# builds it into the type system:
[<Measure>] attaches units to numbers, and the compiler does dimensional analysis.module Main exposing (main)
import Html exposing (text)
type Meters
= Meters Float
type Seconds
= Seconds Float
speed : Meters -> Seconds -> Float
speed (Meters distance) (Seconds time) =
distance / time
main =
text (String.fromFloat (speed (Meters 100) (Seconds 9.58)))
[<Measure>] type meter
[<Measure>] type second
let distance = 100.0<meter>
let time = 9.58<second>
let speed = distance / time
printfn "%f" (float speed)
// distance + time ← would be a COMPILE error: meter + second Dividing meters by seconds infers
float<meter/second> — the units cancel and combine algebraically, and adding a distance to a time simply does not compile. The Elm wrapper-type approach gets the safety but not the algebra; units of measure are one of F#'s genuinely unique features, and they cost nothing at runtime.F# on .NET
Classes, when you need them
Elm has no objects at all. F# is a full .NET citizen, so classes with constructors and members are available — mostly used at the boundary with the object-oriented ecosystem.
module Main exposing (main)
import Html exposing (text)
-- The Elm way: a record of data plus plain functions.
type alias Greeter =
{ name : String }
greet : Greeter -> String
greet greeter =
"Hello, " ++ greeter.name ++ "!"
main =
text (greet { name = "Elm" })
type Greeter(name: string) =
member _.Greet() = $"Hello, {name}!"
let greeter = Greeter("F#")
printfn "%s" (greeter.Greet()) Everything object-oriented in .NET — interfaces, inheritance, properties — is reachable from F#, which is how it borrows the entire C# ecosystem. Idiomatic F# keeps the core functional (records, unions, modules, functions) and uses classes for interop, the same layering Elm enforces by simply not having the object half. Note also what this page could not show: F# values can be
null when they come from .NET libraries — a whole class of problems Elm deleted — so guard the boundary.