Hello World & the Build
Hello, World
Three things Go asks for that OCaml does not: a package declaration, an explicit import, and a named entry point.
let () = print_endline "Hello, World!"package main
import "fmt"
func main() {
fmt.Println("Hello, World!")
}OCaml's
let () = … binds the unit pattern, which runs the expression and asserts it produces nothing. Go requires a package main declaration, an import for anything outside the language itself, and a function called main. The import is not optional and not decorative: an unused import is a compile error, which is the first sign of a language that treats unfinished code as broken code rather than as work in progress.Formatted Output
The directives are C's in both, and only one of the two checks them at compile time.
let name = "OCaml"
let year = 1996
let () = Printf.printf "%s appeared in %d\n" name yearpackage main
import "fmt"
func main() {
name := "Go"
year := 2009
fmt.Printf("%s appeared in %d\n", name, year)
}OCaml's format string is a value of the built-in
format type, so the compiler reads %s and %d and requires exactly a string then an integer — a mismatch is a compile error. Go's is an ordinary string and Printf is variadic over any, so a mismatch compiles and prints %!d(string=Go) at runtime. In practice go vet catches it and runs as part of go test, which is the pattern to expect throughout: Go moves checks the compiler could do into tooling. Note :=, which declares and infers in one step.dune and opam vs the go tool
Configuration rather than code, so neither column runs. The Go column is short because one tool does everything.
(* dune-project *)
(lang dune 3.16)
(* bin/dune *)
(executable
(name main)
(libraries str))
(* Build and run:
dune build
dune exec bin/main.exe
Dependencies come from opam, into a switch. *)// go.mod
// module example.com/demo
// go 1.27
// require github.com/google/uuid v1.6.0
// Build and run:
// go build ./...
// go run .
// go test ./...
//
// One tool does all of it. Dependencies come from their
// own repositories, pinned in go.mod and hashed in go.sum,
// into a shared module cache — there is no switch and no
// environment to activate.OCaml splits the job: opam installs packages into a switch you activate, and dune compiles against whatever that switch holds — so a build depends on shell state the project files do not record. Go has one binary that builds, tests, formats, vets, fetches dependencies and manages versions, with no environment to activate and no global state:
go.mod plus go.sum fully determine the build. There is also no separate build-configuration language — no dune files, no Makefile — because the directory layout is the configuration. That is a real operational advantage and it is one of the main reasons teams pick Go.Subtraction Is the Design
What Is Missing Is Missing on Purpose
Read the Go column as a statement of values rather than as a failure to provide combinators. This is the row that makes the rest of the page make sense.
(* OCaml gives you several ways to express this, and
picking among them is part of the craft. *)
let describe_all items =
items
|> List.filter (fun (_, quantity) -> quantity > 0)
|> List.map (fun (name, quantity) -> Printf.sprintf "%s x%d" name quantity)
|> String.concat ", "
let () = print_endline (describe_all [ ("apple", 2); ("fig", 0); ("pear", 1) ])package main
import (
"fmt"
"strings"
)
// Go gives you a loop. That is the point, not a gap.
func describeAll(items [][2]any) string {
var parts []string
for _, item := range items {
name, quantity := item[0].(string), item[1].(int)
if quantity > 0 {
parts = append(parts, fmt.Sprintf("%s x%d", name, quantity))
}
}
return strings.Join(parts, ", ")
}
func main() {
fmt.Println(describeAll([][2]any{{"apple", 2}, {"fig", 0}, {"pear", 1}}))
}The OCaml version composes three combinators and reads declaratively. The Go version writes the loop out, and Go's designers would say that is better: there is one obvious way to write it, every reader recognizes it immediately, and there is nothing to learn before you can modify it. Go optimizes for a large team reading unfamiliar code, not for the person writing it. Whether that trade is right depends entirely on the situation, and an OCaml programmer who arrives expecting to write OCaml in Go's syntax will be unhappy and will write code their colleagues dislike. The rest of this page is easier to read with that in mind.
One Formatting, Not a Preference
A cultural fact with real engineering consequences, and one of the few things every Go programmer agrees about.
(* ocamlformat exists, is optional, and is configured
per project. Style debates are real. *)
let describe items = List.length items
let () = Printf.printf "%d\n" (describe [ 1; 2; 3 ])package main
import "fmt"
// gofmt is not configurable. Tabs, brace placement,
// alignment and import order are all decided, and every
// editor runs it on save.
func describe(items []int) int {
return len(items)
}
func main() {
fmt.Println(describe([]int{1, 2, 3}))
}gofmt has no options. That was a deliberate decision to end formatting arguments by removing the thing being argued about, and it worked — Go code from any two projects looks the same, diffs contain only real changes, and no team spends a meeting on brace placement. OCaml has ocamlformat, which is excellent, optional, and configurable, so projects differ and adopting it on an existing codebase is a decision with a large diff attached. The Go convention also explains the tabs you will see in every example on this page.Variables & Zero Values
Every Type Has a Zero Value
Go has no uninitialized memory and no constructors either — every type has a defined zero, and you get it whether you wanted it or not.
(* There is no uninitialized binding. A let always binds
something, and a record must have every field. *)
type counter = { total : int; label : string }
let () =
let counter = { total = 0; label = "" } in
Printf.printf "%d %S\n" counter.total counter.labelpackage main
import "fmt"
type Counter struct {
Total int
Label string
}
func main() {
// Declaring without a value gives the ZERO value:
// 0 for numbers, "" for strings, nil for pointers,
// maps, slices, channels and interfaces.
var counter Counter
fmt.Printf("%d %q\n", counter.Total, counter.Label)
}This is genuinely better than C, where reading an uninitialized local is undefined behavior. It is also weaker than OCaml, where the compiler makes you supply every field and there is no "default" to fall through to. The cost shows up as a category of bug OCaml cannot have: a struct that was never properly initialized is indistinguishable from one deliberately set to zero, so "was this configured?" and "was this configured to zero?" are the same question. Go's answer is the convention that the zero value should be useful — an empty
sync.Mutex is an unlocked mutex, an empty bytes.Buffer is ready to write to — which works well when the type is designed for it and not at all when it is not.There Is No Immutability
The single largest thing OCaml has that Go does not offer at all — not even as an opt-in.
(* Immutable by default. A binding cannot be reassigned
and a record field cannot be written unless it was
declared mutable. *)
type point = { x : int; y : int }
let () =
let origin = { x = 0; y = 0 } in
let shifted = { origin with x = 5 } in
Printf.printf "%d %d\n" origin.x shifted.xpackage main
import "fmt"
type Point struct{ X, Y int }
func main() {
// Everything is mutable. const exists but only for
// numbers, strings and booleans — never for a struct,
// a slice or a map.
origin := Point{0, 0}
shifted := origin // structs copy on assignment
shifted.X = 5
fmt.Println(origin.X, shifted.X)
}OCaml is immutable by default and you opt in with
mutable or a ref. Go has no immutability for composite values: const applies only to numbers, strings and booleans, so a struct, slice or map is always writable by anyone holding it. What partly rescues this is that a struct assignment copies — shifted := origin above duplicates the value, so mutating one does not affect the other, which is why the output is 0 5. That does not hold for slices, maps or pointers, which is the subject of the collections section and the most common source of aliasing bugs in Go.Inference Stops at the Function Boundary
Go infers inside a function body and nowhere else, which is the same boundary Rust and TypeScript draw.
(* Whole-program Hindley-Milner, and the polymorphism
comes free: this is 'a list -> int. *)
let count items = List.length items
let add first second = first + second
let () =
Printf.printf "%d\n" (add 3 4);
Printf.printf "%d\n" (count [ "a"; "b"; "c" ])package main
import "fmt"
// Parameters and results are always written out. Inside a
// body, := infers.
func count[T any](items []T) int {
return len(items)
}
func add(first, second int) int {
total := first + second // inferred
return total
}
func main() {
fmt.Println(add(3, 4))
fmt.Println(count([]string{"a", "b", "c"}))
}OCaml discovers a function's type and generalizes it automatically, so
count works for any element type with nothing written down. Go requires every parameter and result, and getting the same generality needs an explicit type parameter — [T any], available only since Go 1.18. Inside a body, := declares and infers. Note the shorthand first, second int, which gives both parameters one type; and note that an unused local variable is a compile error, not a warning, which is the same strictness as the unused import.No Implicit Conversion
Go refuses implicit numeric conversion, which is unusual for a C-family language and matches OCaml exactly.
(* int and float are different types with different
operators, and mixing them does not compile. *)
let () =
let count = 7 in
let average = float_of_int count /. 2.0 in
Printf.printf "%d\n" (count / 2);
Printf.printf "%.1f\n" averagepackage main
import "fmt"
func main() {
count := 7
// int and float64 are distinct: count / 2.0 does not
// compile, and neither does adding an int to an int64.
average := float64(count) / 2.0
fmt.Println(count / 2)
fmt.Printf("%.1f\n", average)
}C will silently widen an
int to a double; Go will not, and neither will OCaml. Go goes further than most: int, int32 and int64 are three distinct types that do not mix without a conversion, even where the widths happen to match. The difference from OCaml is only in spelling — OCaml gives floats their own operators (/.) while Go overloads / and distinguishes by the operand types, so count / 2 is integer division and float64(count) / 2.0 is not.Strings, Bytes & Runes
Strings Are Bytes, Ranging Gives Runes
Both count bytes for the length, and only one of them decodes when you walk the string.
let () =
let text = "caffè" in
Printf.printf "length = %d\n" (String.length text);
String.iter (fun character -> Printf.printf "[%c]" character) text;
print_newline ()package main
import "fmt"
func main() {
text := "caffè"
fmt.Printf("length = %d\n", len(text))
// range over a string decodes UTF-8 and yields runes.
for _, character := range text {
fmt.Printf("[%c]", character)
}
fmt.Println()
}A Go
string is an immutable byte slice, so len is 6 here just as OCaml's String.length is — but Go's is documented to be UTF-8 where OCaml's has no declared encoding. The payoff is that range over a string decodes it and yields rune values (Go's name for a Unicode code point), so the accented letter arrives whole where OCaml's String.iter hands you two mojibake bytes. Indexing with text[0] still gives a byte in both. This is one of the few places Go is straightforwardly ahead of OCaml.Building Strings
The operations correspond one for one, including the reason the second one exists.
let () =
let parts = [ "alpha"; "beta"; "gamma" ] in
print_endline (String.concat " | " parts);
let buffer = Buffer.create 16 in
List.iter (fun part -> Buffer.add_string buffer part) parts;
print_endline (Buffer.contents buffer)package main
import (
"fmt"
"strings"
)
func main() {
parts := []string{"alpha", "beta", "gamma"}
fmt.Println(strings.Join(parts, " | "))
// Concatenating in a loop with += is O(n²); Builder is
// the equivalent of OCaml's Buffer.
var builder strings.Builder
for _, part := range parts {
builder.WriteString(part)
}
fmt.Println(builder.String())
}strings.Join is String.concat with the arguments the other way round, and strings.Builder is Buffer. The reason both languages ship a builder is the same: strings are immutable in both, so appending in a loop reallocates and copies each time, giving quadratic behavior on long inputs. Note the zero-value convention from earlier at work — var builder strings.Builder needs no constructor because the zero value is a usable empty builder.Slices & Maps
Slices Are Views, and They Alias
The most important collection fact in Go, and the one that produces its most confusing bugs.
(* An immutable linked list. Consing shares structure and
the original is untouched, always. *)
let () =
let numbers = [ 1; 2; 3 ] in
let extended = 0 :: numbers in
Printf.printf "extended %d, original %d\n"
(List.length extended) (List.length numbers)package main
import "fmt"
func main() {
numbers := []int{1, 2, 3}
// A slice is a VIEW: pointer, length, capacity. Two
// slices can share the same backing array.
view := numbers[:2]
view[0] = 99
fmt.Println(numbers[0], view[0]) // both 99
// append MAY reallocate, and whether it does depends on
// spare capacity — so aliasing is not predictable.
appended := append(numbers, 4)
appended[1] = 77
fmt.Println(numbers[1], appended[1])
}A Go slice is a three-word header — pointer, length, capacity — over a backing array, so slicing does not copy and two slices can write to the same memory. That makes
view[0] = 99 visible through numbers. Worse, append reallocates only when capacity runs out, so whether a modification is shared depends on how much spare capacity the slice happened to have — the same code can alias or not depending on how the slice was built. OCaml's list makes the question impossible: it is immutable, so sharing is invisible. The Go habits are to copy explicitly when you mean to (slices.Clone) and to treat any slice you did not create as read-only unless documented otherwise.Maps
Go's map lookup has two forms, and reaching for the shorter one is how a missing key becomes a zero.
let () =
let ages = Hashtbl.create 8 in
Hashtbl.replace ages "ada" 36;
(match Hashtbl.find_opt ages "ada" with
| Some age -> Printf.printf "ada is %d\n" age
| None -> print_endline "unknown");
(match Hashtbl.find_opt ages "alan" with
| Some age -> Printf.printf "alan is %d\n" age
| None -> print_endline "unknown")package main
import "fmt"
func main() {
ages := map[string]int{"ada": 36}
// The two-value form is how absence is reported: a
// missing key otherwise yields the ZERO value silently.
if age, found := ages["ada"]; found {
fmt.Printf("ada is %d\n", age)
} else {
fmt.Println("unknown")
}
if age, found := ages["alan"]; found {
fmt.Printf("alan is %d\n", age)
} else {
fmt.Println("unknown")
}
}OCaml's
find_opt returns an option, so the absent case cannot be skipped. Go's ages["alan"] returns 0 — the zero value for int — with no indication that the key was missing, and the value, found := m[key] form is the only way to tell "absent" from "present and zero". That is the same trap as the zero-value row, and it is the single most common Go bug an OCaml programmer will introduce. Note also that map iteration order is deliberately randomized, so code that accidentally depends on ordering fails quickly rather than in production.Iterating
The idiomatic Go answer to a pipeline is one loop, and it is shorter here than the combinator version.
let () =
let numbers = [ 1; 2; 3; 4; 5; 6 ] in
let total =
numbers
|> List.filter (fun number -> number mod 2 = 0)
|> List.map (fun number -> number * 2)
|> List.fold_left ( + ) 0
in
Printf.printf "total = %d\n" totalpackage main
import "fmt"
func main() {
numbers := []int{1, 2, 3, 4, 5, 6}
total := 0
for _, number := range numbers {
if number%2 == 0 {
total += number * 2
}
}
fmt.Printf("total = %d\n", total)
}Go has
slices.Sorted, slices.Contains and friends, and since 1.23 it has range-over-function iterators — but there is no map/filter/reduce in the standard library and idiomatic code does not reach for them. The single loop does the whole pipeline in one pass with no intermediate slices, which the OCaml version allocates two of. What is lost is composition: each stage of the OCaml pipeline is a value that can be named, reused and tested, while the Go loop is a block that does one specific thing.Sorting
Both sort with a three-way comparison. Only one of them modifies the slice you handed it.
let () =
let words = [ "banana"; "fig"; "apple" ] in
let by_length =
List.sort (fun left right ->
compare (String.length left) (String.length right)) words
in
print_endline (String.concat " " by_length);
print_endline (String.concat " " (List.sort compare words))package main
import (
"fmt"
"slices"
"strings"
)
func main() {
words := []string{"banana", "fig", "apple"}
// SortFunc sorts IN PLACE, so clone to keep the original.
byLength := slices.Clone(words)
slices.SortFunc(byLength, func(left, right string) int {
return len(left) - len(right)
})
fmt.Println(strings.Join(byLength, " "))
slices.Sort(words)
fmt.Println(strings.Join(words, " "))
}OCaml's
List.sort returns a new list and leaves the original alone, because the list is immutable. Go's slices.Sort and slices.SortFunc sort in place and return nothing, so the original ordering is gone unless you cloned first — which is why slices.Clone appears above. Both use the C convention of a comparison returning a negative number, zero or a positive one. Note that len(left) - len(right) is safe here because lengths are small; on arbitrary integers that subtraction can overflow, and the correct spelling is cmp.Compare.Control Flow
Statements, Not Expressions
Go has no conditional expression at all — not even the ternary that almost every other C-family language kept.
let () =
let temperature = 31 in
let advice =
if temperature > 30 then "stay inside"
else if temperature > 20 then "pleasant"
else "bring a coat"
in
print_endline advicepackage main
import "fmt"
func main() {
temperature := 31
// if produces no value, and there is no ternary — Go
// removed it on purpose.
var advice string
if temperature > 30 {
advice = "stay inside"
} else if temperature > 20 {
advice = "pleasant"
} else {
advice = "bring a coat"
}
fmt.Println(advice)
}In OCaml everything produces a value, so
if can be bound directly and every branch must agree on a type. Go's if is a statement, so the variable is declared first and assigned in each branch — and if a branch is missed it silently keeps its zero value, which is the zero-value trap once more. The absence of a ternary is deliberate: the Go FAQ says nested ternaries become unreadable, so the language does without. An OCaml programmer will find this the most tiring difference in daily use.switch Does Not Fall Through
Go's
switch is better than C's in every way that matters, and still much weaker than a match.let classify number =
match number with
| 0 -> "zero"
| n when n < 0 -> "negative"
| 1 | 2 | 3 -> "small"
| _ -> "ordinary"
let () =
List.iter
(fun number -> Printf.printf "%d is %s\n" number (classify number))
[ 0; -5; 2; 42 ]package main
import "fmt"
func classify(number int) string {
switch {
case number == 0:
return "zero"
case number < 0:
return "negative"
case number == 1, number == 2, number == 3:
return "small"
default:
return "ordinary"
}
}
func main() {
for _, number := range []int{0, -5, 2, 42} {
fmt.Printf("%d is %s\n", number, classify(number))
}
}Go fixed C's worst default: cases do not fall through, so no
break is needed and fallthrough must be asked for. A bare switch with no subject takes boolean cases, which is how a guard is expressed, and comma-separated values give an or-pattern. What it cannot do is destructure — there is no binding of sub-parts, no nested patterns, and no exhaustiveness checking anywhere. The default above is not optional politeness; without it the function would fall off the end, which Go reports as "missing return".defer
Go's answer to cleanup, and it is placed next to the acquisition rather than wrapped around the body.
(* Fun.protect runs the finalizer on every exit path,
including an exception. *)
let () =
Fun.protect
~finally:(fun () -> print_endline "released")
(fun () -> print_endline "using it")package main
import "fmt"
func work() {
// defer runs when the FUNCTION returns, however it
// returns — including on a panic.
defer fmt.Println("released")
fmt.Println("using it")
}
func main() {
work()
}OCaml's
Fun.protect takes the body as a function and a ~finally to run afterwards, which works and reads as a higher-order call. Go's defer registers the cleanup at the point you acquire the thing and runs it when the enclosing function returns — so file, err := os.Open(…) followed immediately by defer file.Close() puts the two lines together, which is genuinely easier to get right than remembering a wrapper. Deferred calls run last-in-first-out and their arguments are evaluated immediately, which is the one surprise: defer fmt.Println(counter) prints the value at the defer, not at return.for Is the Only Loop
Another subtraction: Go has exactly one loop keyword, and it wears four different shapes.
let () =
for index = 1 to 3 do
Printf.printf "counting %d\n" index
done;
let countdown = ref 2 in
while !countdown > 0 do
Printf.printf "while %d\n" !countdown;
countdown := !countdown - 1
donepackage main
import "fmt"
func main() {
// One keyword covers counting, while, forever and
// iteration. There is no while and no do-while.
for index := 1; index <= 3; index++ {
fmt.Printf("counting %d\n", index)
}
countdown := 2
for countdown > 0 { // this IS the while loop
fmt.Printf("while %d\n", countdown)
countdown--
}
}for with three clauses counts, with one clause it is a while, with none it loops forever, and with range it iterates. OCaml has for … to and while as separate constructs, and its while needs a ref because there are no mutable local bindings. Since Go 1.22 for range 3 also counts without a variable, which is the newest and tersest form. Go has break and continue, plus labeled versions for breaking out of nested loops — OCaml has none of these, so an early exit means recursion or an exception.Functions
No Currying, No Default Arguments
Three OCaml conveniences go at once: currying, labeled arguments and optional arguments.
let add first second = first + second
let greet ?(greeting = "Hello") ~name () =
Printf.sprintf "%s, %s!" greeting name
let () =
let add_ten = add 10 in
Printf.printf "%d\n" (add_ten 5);
print_endline (greet ~name:"Ada" ())package main
import "fmt"
func add(first, second int) int { return first + second }
// No default arguments and no named ones. A closure gives
// partial application; an options struct gives defaults.
func main() {
addTen := func(second int) int { return add(10, second) }
fmt.Println(addTen(5))
greeting := "Hello"
name := "Ada"
fmt.Printf("%s, %s!\n", greeting, name)
}Go functions take exactly their parameters, positionally, with no defaults and no names at the call site. Partial application means writing a closure out, as above. The community answer to optional configuration is either an options struct or the "functional options" pattern — a variadic list of
func(*Config) values — which is verbose enough that most APIs simply require all their arguments. Go does have variadic parameters (parts ...string) and multiple return values, which is what it uses instead of tuples.Multiple Return Values
This looks like a tuple and is not one, and the difference explains Go's entire error-handling design.
let divide_and_remainder numerator denominator =
(numerator / denominator, numerator mod denominator)
let () =
let (quotient, remainder) = divide_and_remainder 17 5 in
Printf.printf "%d remainder %d\n" quotient remainderpackage main
import "fmt"
// Not a tuple: a function genuinely returns two values.
func divideAndRemainder(numerator, denominator int) (int, int) {
return numerator / denominator, numerator % denominator
}
func main() {
quotient, remainder := divideAndRemainder(17, 5)
fmt.Printf("%d remainder %d\n", quotient, remainder)
}OCaml returns a tuple — one value that happens to have two components, which can be named, stored, passed on and pattern-matched. Go returns two values, and there is no type that holds them: you cannot store the result of
divideAndRemainder in a single variable, pass it onward as one thing, or make a slice of them. That is why (T, error) is a returning convention rather than a Result type, and why Go's error handling cannot be composed the way OCaml's result can. It is the most consequential small difference on the page.Closures
Closures capture by reference in both, and Go needs no cell because its locals are already mutable.
let make_counter () =
let total = ref 0 in
fun () ->
total := !total + 1;
!total
let () =
let next = make_counter () in
(* Sequenced deliberately: OCaml does not specify the
order in which a function's arguments are evaluated,
so printing all three in one call is not 1 2 3. *)
Printf.printf "%d " (next ());
Printf.printf "%d " (next ());
Printf.printf "%d\n" (next ())package main
import "fmt"
func makeCounter() func() int {
total := 0
return func() int {
total++
return total
}
}
func main() {
next := makeCounter()
fmt.Println(next(), next(), next())
}The two are the same idea. OCaml needs a
ref because it has no mutable local bindings, so the counter lives in a heap cell; Go's total is an ordinary local that the closure captures, and the compiler moves it to the heap automatically because it escapes. The anchor column sequences its three calls on purpose, and the comment says why: OCaml does not specify the order in which a function's arguments are evaluated and in practice evaluates right to left, so Printf.printf "%d %d %d" (next ()) (next ()) (next ()) prints 3 2 1. Go evaluates arguments left to right. That is a genuine trap in both directions and a good reason not to put side effects in arguments.Structs & Methods
Structs and Methods
Go attaches methods to a type without a class, by naming a receiver in front of the function.
type rectangle = { width : float; height : float }
let area rectangle = rectangle.width *. rectangle.height
let scaled rectangle factor =
{ width = rectangle.width *. factor; height = rectangle.height *. factor }
let () =
let small = { width = 3.0; height = 4.0 } in
Printf.printf "%.1f\n" (area small);
Printf.printf "%.1f\n" (area (scaled small 2.0))package main
import "fmt"
type Rectangle struct {
Width, Height float64
}
// A method is a function with a RECEIVER before the name.
func (r Rectangle) Area() float64 { return r.Width * r.Height }
func (r Rectangle) Scaled(factor float64) Rectangle {
return Rectangle{r.Width * factor, r.Height * factor}
}
func main() {
small := Rectangle{3, 4}
fmt.Printf("%.1f\n", small.Area())
fmt.Printf("%.1f\n", small.Scaled(2).Area())
}OCaml keeps data and the functions over it separate —
area is a plain function whose first argument is a rectangle. Go's receiver syntax gives the same function dot-call notation and lets the type satisfy interfaces, which is the whole mechanism the next section rests on. Two details matter: the receiver may be a value (a copy, as here) or a pointer (func (r *Rectangle)), and only a pointer receiver can mutate; and capitalization is visibility — Width is exported from the package and width would not be, which is Go's entire access-control system.Embedding, Not Inheritance
Embedding looks like inheritance and is composition with automatic delegation — a genuinely different thing.
(* Composition is explicit: put one record inside
another and write the delegating functions. *)
type engine = { horsepower : int }
type car = { engine : engine; name : string }
let horsepower car = car.engine.horsepower
let () =
let car = { engine = { horsepower = 150 }; name = "saloon" } in
Printf.printf "%s has %d\n" car.name (horsepower car)package main
import "fmt"
type Engine struct{ Horsepower int }
func (e Engine) Describe() string {
return fmt.Sprintf("%d hp", e.Horsepower)
}
// An embedded field has no name, and its methods and
// fields are PROMOTED to the outer type.
type Car struct {
Engine
Name string
}
func main() {
car := Car{Engine{150}, "saloon"}
fmt.Printf("%s has %s\n", car.Name, car.Describe())
fmt.Println(car.Horsepower)
}Writing
Engine with no field name embeds it, and every method and field of Engine becomes callable directly on Car. That saves the delegating functions the OCaml column writes by hand. What it is not is inheritance: there is no subtyping, a Car is not an Engine, and there is no virtual dispatch — Describe on the embedded value cannot be overridden in a way the embedded code will call. Go has no inheritance at all, which is another deliberate subtraction.Struct Tags and JSON
JSON is in the standard library, and the mapping is expressed in a string the compiler does not read.
(* OCaml's standard library has no JSON. yojson plus a
ppx deriver supplies it, with a checked conversion —
this column builds the text by hand to stay runnable. *)
type person = { name : string; age : int }
let to_json person =
Printf.sprintf {|{"name":"%s","age":%d}|} person.name person.age
let () = print_endline (to_json { name = "Ada"; age = 36 })package main
import (
"encoding/json"
"fmt"
)
// A struct tag is a string literal read by REFLECTION at
// run time — not by the compiler, so a typo is silent.
type Person struct {
Name string `json:"name"`
Age int `json:"age"`
}
func main() {
encoded, _ := json.Marshal(Person{"Ada", 36})
fmt.Println(string(encoded))
}OCaml has no JSON in its standard library;
yojson with ppx_deriving_yojson supplies it, and the derived conversion is generated at compile time and type-checked. Go's encoding/json reads struct tags by reflection at run time, so json:"name" is an ordinary string the compiler never validates — misspell it as jsonn:"name" and the field silently marshals under its Go name. That is the trade Go makes repeatedly: reflection gives you a serializer that works for any struct with no code generation, at the cost of moving errors from compile time to run time. go vet does check tag syntax, which recovers part of it.There Are No Sum Types
The Largest Thing You Give Up
The biggest loss on the page, and worth measuring precisely rather than lamenting.
type shape =
| Circle of float
| Rectangle of float * float
| Point
let area = function
| Circle radius -> 3.14159 *. radius *. radius
| Rectangle (width, height) -> width *. height
| Point -> 0.0
let () =
List.iter
(fun shape -> Printf.printf "%.2f\n" (area shape))
[ Circle 1.0; Rectangle (2.0, 3.0); Point ]package main
import "fmt"
// The closest Go gets: an interface with a marker method,
// and a type switch. Nothing checks that the set is
// closed, and nothing checks that you handled it all.
type Shape interface{ isShape() }
type Circle struct{ Radius float64 }
type Rectangle struct{ Width, Height float64 }
type Point struct{}
func (Circle) isShape() {}
func (Rectangle) isShape() {}
func (Point) isShape() {}
func area(shape Shape) float64 {
switch value := shape.(type) {
case Circle:
return 3.14159 * value.Radius * value.Radius
case Rectangle:
return value.Width * value.Height
case Point:
return 0
}
return 0 // unreachable, and the compiler cannot know that
}
func main() {
for _, shape := range []Shape{Circle{1}, Rectangle{2, 3}, Point{}} {
fmt.Printf("%.2f\n", area(shape))
}
}An OCaml variant guarantees the set of cases is closed and that every one is handled. Go's nearest construction gives neither. The unexported
isShape() marker keeps other packages from adding cases, which is a real if partial substitute for closedness — but nothing prevents a fourth type in this package, and the type switch has no exhaustiveness check at all, so adding Triangle silently falls through to that trailing return 0. The trailing return is not defensive style; the compiler requires it because it cannot prove the switch covers everything. Linters (exhaustive) can check this, and are not run by default.And No option Either
The
value, ok convention is Go's option, and it is a convention rather than a type.(* Absence has a different type from presence, so the
compiler forces the case open. *)
let find_even numbers = List.find_opt (fun number -> number mod 2 = 0) numbers
let () =
(match find_even [ 1; 3; 4 ] with
| Some number -> Printf.printf "found %d\n" number
| None -> print_endline "none found");
(match find_even [ 1; 3; 5 ] with
| Some number -> Printf.printf "found %d\n" number
| None -> print_endline "none found")package main
import "fmt"
// The convention is a second boolean return. Nothing
// forces the caller to look at it.
func findEven(numbers []int) (int, bool) {
for _, number := range numbers {
if number%2 == 0 {
return number, true
}
}
return 0, false
}
func main() {
for _, candidates := range [][]int{{1, 3, 4}, {1, 3, 5}} {
if number, found := findEven(candidates); found {
fmt.Printf("found %d\n", number)
} else {
fmt.Println("none found")
}
}
}OCaml's
int option is a different type from int, so the value cannot be used without deciding what to do about absence. Go's pair is two independent values, so number, _ := findEven(…) compiles and hands you 0 — indistinguishable from finding a genuine zero. Go 1.18's generics do make a real Option[T] possible to write, and the community mostly does not, because it does not compose with the standard library and reads as foreign. Note that this is the same shape as the map lookup and the error return: Go has one idea, a second boolean or error result, and uses it everywhere OCaml would use a sum type.Enumerations Are Integers
The
iota idiom is Go's enumeration, and like C's it is an integer wearing a name.(* Constant constructors form a real type. A value of
type status is one of exactly three things, and the
match is checked. *)
type status = Pending | Active | Closed
let describe = function
| Pending -> "waiting"
| Active -> "running"
| Closed -> "finished"
let () =
List.iter (fun status -> print_endline (describe status))
[ Pending; Active; Closed ]package main
import "fmt"
type Status int
// iota counts up from 0 within a const block.
const (
Pending Status = iota
Active
Closed
)
func (s Status) String() string {
switch s {
case Pending:
return "waiting"
case Active:
return "running"
case Closed:
return "finished"
}
return "unknown" // required: Status is an int, so any int is one
}
func main() {
for _, status := range []Status{Pending, Active, Closed} {
fmt.Println(status)
}
}OCaml's
status has exactly three values and no way to produce a fourth. A Go named integer type accepts any integer: Status(42) compiles, which is why the trailing return "unknown" is a necessity rather than defensive clutter. What Go adds is the String() method — implementing fmt.Stringer means fmt.Println formats the value automatically, which is a genuinely nice piece of interface design. The stringer code generator writes that method for you, and go vet can be extended to check exhaustiveness, but neither is on by default.Interfaces
Interfaces Are Satisfied Implicitly
This is the thing Go does best, and it is closer to OCaml's object system than to its modules.
module type Describable = sig
type t
val describe : t -> string
end
module IntDescription : Describable with type t = int = struct
type t = int
let describe value = Printf.sprintf "the number %d" value
end
let () = print_endline (IntDescription.describe 42)package main
import "fmt"
type Describable interface {
Describe() string
}
// Number never mentions Describable. It satisfies the
// interface by having the method — structurally, like
// OCaml's objects rather than its module signatures.
type Number int
func (n Number) Describe() string {
return fmt.Sprintf("the number %d", int(n))
}
func describeIt(value Describable) { fmt.Println(value.Describe()) }
func main() {
describeIt(Number(42))
}An OCaml signature constrains a module, and the caller must name that module. A Go interface constrains a type, structurally:
Number never mentions Describable, and satisfies it by having a matching method. That means you can define an interface for a type somebody else wrote, after the fact, which is genuinely powerful — the standard library's io.Reader and io.Writer are one method each, and half the ecosystem composes through them. The Go convention that follows is worth adopting: define small interfaces at the point of use, not next to the implementation.Dynamic Dispatch
Putting different implementations in one list is ordinary in Go and needs explicit packing in OCaml.
module type Greeter = sig val greet : unit -> string end
module English = struct let greet () = "Hello" end
module French = struct let greet () = "Bonjour" end
let () =
let greeters : (module Greeter) list = [ (module English); (module French) ] in
List.iter
(fun greeter ->
let module G = (val greeter : Greeter) in
print_endline (G.greet ()))
greeterspackage main
import "fmt"
type Greeter interface{ Greet() string }
type English struct{}
type French struct{}
func (English) Greet() string { return "Hello" }
func (French) Greet() string { return "Bonjour" }
func main() {
for _, greeter := range []Greeter{English{}, French{}} {
fmt.Println(greeter.Greet())
}
}OCaml needs a first-class module, packed with
(module English) and unpacked with (val …), because a module is not a value by default. In Go an interface value is a value — a pair of a type descriptor and a pointer — so a slice of them needs no ceremony. This is the ergonomic advantage of putting the abstraction on types rather than modules, and it is why Go code reaches for interfaces constantly where OCaml code reaches for them almost never. The cost is that dispatch is dynamic and unspecialized, where OCaml's functors resolve at compile time.any, and the Type Assertion
Go's escape hatch from the type system, and the reason a type switch always needs a default.
(* There is no universal type. A heterogeneous
collection needs a variant declaring what may be in
it, and the match is checked. *)
type value = Text of string | Number of int
let describe = function
| Text text -> Printf.sprintf "text %s" text
| Number number -> Printf.sprintf "number %d" number
let () =
List.iter (fun value -> print_endline (describe value))
[ Text "hi"; Number 42 ]package main
import "fmt"
// any is the empty interface: every type satisfies it, so
// a []any holds anything and the type system knows nothing.
func describe(value any) string {
switch typed := value.(type) {
case string:
return fmt.Sprintf("text %s", typed)
case int:
return fmt.Sprintf("number %d", typed)
default:
return "unknown"
}
}
func main() {
for _, value := range []any{"hi", 42} {
fmt.Println(describe(value))
}
}any is an alias for interface{}, the interface with no methods, which every type satisfies — so a value of that type carries no information the compiler can use, and getting anything out requires a type assertion or a type switch. It is Go's Obj.t, except ordinary rather than alarming, and it was the only way to write a generic container before 1.18. The default branch is mandatory in spirit: the set of types that could arrive is every type, so no switch over any can be exhaustive. Note the two-value assertion form, text, ok := value.(string), which reports failure instead of panicking.Accept Interfaces, Return Structs
The one piece of Go design advice worth memorizing, and it comes straight from how interfaces are satisfied.
(* A functor fixes the abstraction at instantiation, so
the caller names the module and gets a concrete one
back. *)
module type Source = sig val read : unit -> string end
module Fixed = struct let read () = "fixed input" end
module Reader (S : Source) = struct
let describe () = "read: " ^ S.read ()
end
module FixedReader = Reader (Fixed)
let () = print_endline (FixedReader.describe ())package main
import "fmt"
type Source interface{ Read() string }
type Fixed struct{}
func (Fixed) Read() string { return "fixed input" }
// The convention: take the narrowest interface you need,
// return the concrete type. Callers get everything the
// concrete type offers and can substitute anything.
func describe(source Source) string { return "read: " + source.Read() }
func main() {
fmt.Println(describe(Fixed{}))
}"Accept interfaces, return structs" works because satisfaction is implicit: a caller can pass anything with the right method without either side coordinating, and returning the concrete type means the caller keeps every method rather than only the ones you chose to expose. The corollary is that interfaces belong at the point of use —
describe declares the one method it needs — rather than beside the implementation, which is the opposite of the Java habit. OCaml's functor fixes the abstraction at instantiation instead, which is checked more thoroughly and is far less fluid.Generics
Generics, Since 1.18
Go got generics in 2022, and they are narrower than OCaml's parametric polymorphism in ways worth knowing before you plan around them.
(* Inferred and generalized automatically, with no type
parameter written down. *)
let first_or items fallback =
match items with
| [] -> fallback
| head :: _ -> head
let () =
Printf.printf "%d\n" (first_or [ 1; 2 ] 0);
print_endline (first_or [] "empty")package main
import "fmt"
// The type parameter and its constraint are written out;
// the instantiation is usually inferred at the call.
func firstOr[T any](items []T, fallback T) T {
if len(items) == 0 {
return fallback
}
return items[0]
}
func main() {
fmt.Println(firstOr([]int{1, 2}, 0))
fmt.Println(firstOr([]string{}, "empty"))
}OCaml infers
'a list -> 'a -> 'a and generalizes automatically — polymorphism is the default rather than a feature. Go needs [T any] written out, with a constraint interface saying what operations are allowed. Three real limits: methods cannot have their own type parameters, so a generic Map method on a type is impossible; there is nothing higher-kinded, so no abstraction over []T versus map[K]T; and constraints cannot express "has a field", only "has a method" or "is one of these types". The result is that generics are used for containers and small utilities and rarely for architecture.Errors Are Values
No Exceptions, Ever
An error is an ordinary return value implementing a one-method interface, and nothing propagates on its own.
exception Too_large of int
let check value =
if value > 100 then raise (Too_large value) else value
let () =
Printf.printf "%d\n" (check 50);
(try Printf.printf "%d\n" (check 500) with
| Too_large value -> Printf.printf "too large: %d\n" value)package main
import (
"errors"
"fmt"
)
type TooLargeError struct{ Value int }
func (e TooLargeError) Error() string {
return fmt.Sprintf("too large: %d", e.Value)
}
func check(value int) (int, error) {
if value > 100 {
return 0, TooLargeError{value}
}
return value, nil
}
func main() {
if value, err := check(50); err == nil {
fmt.Println(value)
}
var tooLarge TooLargeError
if _, err := check(500); errors.As(err, &tooLarge) {
fmt.Printf("too large: %d\n", tooLarge.Value)
}
}OCaml exceptions propagate until something catches them, so a function that cannot handle an error need not mention it. Go has no exceptions for this purpose at all: an error is a value, every fallible call returns one, and the caller must decide something at every step. That is closer to OCaml's
result than to its exceptions — except that nothing forces the check, since value, _ := check(500) compiles. Modern Go wraps errors with fmt.Errorf("...: %w", err) to build a chain, and inspects them with errors.Is for sentinel values and errors.As for typed ones, as above.The if err != nil Tax
The most-complained-about thing in Go, and the complaint is fair — but so is the defense.
(* A binding operator flattens the chain to one line
per step, and the failure path is written once. *)
let ( let* ) = Result.bind
let parse text =
match int_of_string_opt text with
| Some number -> Ok number
| None -> Error (Printf.sprintf "%S is not a number" text)
let total first second third =
let* a = parse first in
let* b = parse second in
let* c = parse third in
Ok (a + b + c)
let () =
(match total "1" "2" "3" with
| Ok value -> Printf.printf "%d\n" value
| Error message -> print_endline message);
(match total "1" "oops" "3" with
| Ok value -> Printf.printf "%d\n" value
| Error message -> print_endline message)package main
import (
"fmt"
"strconv"
)
// Three steps, three checks. There is no ?, no bind, and
// no way to write the failure path once.
func total(first, second, third string) (int, error) {
a, err := strconv.Atoi(first)
if err != nil {
return 0, fmt.Errorf("%q is not a number", first)
}
b, err := strconv.Atoi(second)
if err != nil {
return 0, fmt.Errorf("%q is not a number", second)
}
c, err := strconv.Atoi(third)
if err != nil {
return 0, fmt.Errorf("%q is not a number", third)
}
return a + b + c, nil
}
func main() {
for _, attempt := range [][3]string{{"1", "2", "3"}, {"1", "oops", "3"}} {
if value, err := total(attempt[0], attempt[1], attempt[2]); err != nil {
fmt.Println(err)
} else {
fmt.Println(value)
}
}
}OCaml's binding operator collapses the chain to one line per step with the failure path written once; Rust's
? does the same in one character. Go has neither and has repeatedly declined to add one, on the grounds that error handling is program logic and should be as visible as any other logic — that a ? makes it easy to propagate an error without thinking about whether propagating is right. The counterargument is the column above: three-fifths of that function is machinery, and the repetition makes it easy to return the wrong variable. Both are true. It is the clearest case on this page of Go choosing explicitness over concision.panic Is for Bugs
Go does have a stack-unwinding mechanism, and the culture around it is that reaching for it is a mistake.
(* failwith raises Failure, an ordinary catchable
exception — and catching it is entirely normal. *)
let divide numerator denominator =
if denominator = 0 then failwith "divide by zero";
numerator / denominator
let () =
Printf.printf "%d\n" (divide 10 2);
(try Printf.printf "%d\n" (divide 10 0) with
| Failure message -> Printf.printf "recovered: %s\n" message)package main
import "fmt"
func divide(numerator, denominator int) int {
if denominator == 0 {
panic("divide by zero")
}
return numerator / denominator
}
func main() {
fmt.Println(divide(10, 2))
// recover exists, and using it for control flow is
// considered wrong. This is a demonstration, not a
// pattern to copy.
func() {
defer func() {
if recovered := recover(); recovered != nil {
fmt.Println("recovered:", recovered)
}
}()
fmt.Println(divide(10, 0))
}()
}panic unwinds, running deferred functions on the way, and recover inside a deferred function stops it — so mechanically it is an exception. The convention is that it is only for programmer error: an impossible state, a broken invariant, a nil map write. Using it for expected failures is what the error return exists to prevent, and code review will say so. The legitimate exception is a library boundary, where a package may recover internally and return an error rather than let a panic escape. OCaml, by contrast, uses exceptions freely and idiomatically for ordinary control flow.Sentinel Errors and Wrapping
Wrapping builds a chain that carries context without losing the identity of the original failure.
(* An exception is a declared constructor, so matching
on it is pattern matching and the payload comes with
it. *)
exception Not_found_error of string
let lookup key =
if key = "known" then "value" else raise (Not_found_error key)
let () =
(try print_endline (lookup "known") with
| Not_found_error key -> Printf.printf "missing: %s\n" key);
(try print_endline (lookup "other") with
| Not_found_error key -> Printf.printf "missing: %s\n" key)package main
import (
"errors"
"fmt"
)
// A sentinel is a package-level error value compared with
// errors.Is, which unwraps a chain built by %w.
var ErrNotFound = errors.New("not found")
func lookup(key string) (string, error) {
if key == "known" {
return "value", nil
}
return "", fmt.Errorf("looking up %q: %w", key, ErrNotFound)
}
func main() {
for _, key := range []string{"known", "other"} {
value, err := lookup(key)
if errors.Is(err, ErrNotFound) {
fmt.Printf("missing: %s\n", key)
} else {
fmt.Println(value)
}
}
}%w in fmt.Errorf wraps an error, producing a new one whose message adds context and whose chain still contains the original. errors.Is walks that chain comparing against a sentinel, and errors.As walks it looking for a type. That combination is genuinely good: an error can accumulate "looking up X: opening Y: permission denied" as it propagates while the caller can still ask "was this fundamentally a permission problem?". OCaml's exceptions carry a payload but have no wrapping convention, so context is usually added by catching and re-raising a different exception, which loses the original unless you carry it explicitly.nil
nil Is in Six Types
Go has null, it is spelled
nil, and it means something different in each of the six types that can hold it.(* There is no null. An absent value has a different
type, and the compiler forces the case open. *)
let () =
let maybe_name : string option = None in
match maybe_name with
| Some name -> print_endline name
| None -> print_endline "absent"package main
import "fmt"
func main() {
// nil is the zero value of pointers, slices, maps,
// channels, functions and interfaces — six different
// kinds of nothing with different behaviors.
var pointer *int
var slice []int
var lookup map[string]int
fmt.Println(pointer == nil, slice == nil, lookup == nil)
// A nil slice is USABLE: len, range and append work.
fmt.Println(len(slice), append(slice, 1))
// A nil map READS fine and panics on write.
fmt.Println(lookup["missing"])
}OCaml has no null at all: absence is
option, a different type the compiler makes you handle. Go's nil is the zero value of six kinds, and their behaviors differ — a nil slice is fully usable (len is 0, append works, range does nothing), a nil map reads as zero values and panics on write, and a nil pointer panics on dereference. Knowing which is which is a real part of learning Go, and the useful nil slice is why Go code so rarely initializes one explicitly.The Typed-nil Trap
The most notorious footgun in the language, and it has bitten every Go programmer at least once.
(* Nothing analogous exists: None is None, and there is
no way for an option to be "present but empty". *)
let () =
let value : string option = None in
Printf.printf "%b\n" (value = None)package main
import "fmt"
type MyError struct{}
func (MyError) Error() string { return "boom" }
// Returning a nil *MyError as an error gives an interface
// that is NOT nil — it holds a type and a nil pointer.
func broken() error {
var pointer *MyError
return pointer
}
func fixed() error { return nil }
func main() {
fmt.Println(broken() == nil) // false — the classic bug
fmt.Println(fixed() == nil) // true
}An interface value is a pair: a type descriptor and a value pointer. It is
nil only when both halves are nil. Assigning a nil *MyError to an error gives an interface whose type half is set, so err != nil is true and the caller reports a failure that did not happen. The fix is to return a literal nil, never a typed nil pointer — which means never declaring an error variable of a concrete pointer type and returning it. OCaml cannot express the situation: None is None, with no room for a present-but-empty state.Goroutines & Channels
Goroutines Are Cheap
The reason Go exists, and the one place it is unambiguously ahead of OCaml today.
(* A domain is an OS thread with its own minor heap:
expensive enough that you create a few, not thousands. *)
let () =
let square value = Domain.spawn (fun () -> value * value) in
let workers = List.map square [ 1; 2; 3; 4 ] in
let results = List.map Domain.join workers in
List.iter (Printf.printf "%d ") results;
print_newline ()package main
import (
"fmt"
"sync"
)
func main() {
results := make([]int, 4)
var waiting sync.WaitGroup
for index, value := range []int{1, 2, 3, 4} {
waiting.Add(1)
// A goroutine starts at a few kilobytes of stack
// and grows. Millions are ordinary.
go func() {
defer waiting.Done()
results[index] = value * value
}()
}
waiting.Wait()
for _, result := range results {
fmt.Print(result, " ")
}
fmt.Println()
}An OCaml 5
Domain is an operating-system thread with its own minor heap, so the guidance is to create roughly as many as you have cores and schedule work across them with a library. A goroutine starts with a small growable stack and is multiplexed onto OS threads by the runtime, so a goroutine per request is the normal design and hundreds of thousands are unremarkable. Note also that since Go 1.22 the loop variables are per-iteration, so capturing index and value in the closure is safe — before that this exact code was the language's most famous bug, and older examples pass them as arguments.Channels and select
A channel is a typed, synchronizing queue in the language itself — not a library type.
(* OCaml's standard library gives domains and mutexes and
stops there; channels come from Domainslib. This shows
the shape with a mutex-protected queue. *)
let () =
let queue = Queue.create () in
let lock = Mutex.create () in
let producer = Domain.spawn (fun () ->
for index = 1 to 3 do
Mutex.lock lock;
Queue.add index queue;
Mutex.unlock lock
done)
in
Domain.join producer;
Queue.iter (Printf.printf "%d ") queue;
print_newline ()package main
import "fmt"
func main() {
numbers := make(chan int)
go func() {
for index := 1; index <= 3; index++ {
numbers <- index
}
close(numbers) // closing is how the range ends
}()
for number := range numbers {
fmt.Print(number, " ")
}
fmt.Println()
}Go builds CSP into the language:
chan int is a type, <- is an operator, close ends a range, and select waits on several channels at once with a default for the non-blocking case. That last one has no OCaml equivalent at all and is what makes timeouts and cancellation composable. OCaml 5's standard library provides domains and mutexes and leaves channels, task pools and parallel iteration to Domainslib — a deliberate choice to keep the core small, and one that means the ergonomic layer is a dependency rather than a given.Shared Mutable State
Both reach 2000, and neither language ties the lock to the data it protects.
let () =
let lock = Mutex.create () in
let total = ref 0 in
let bump () =
for _ = 1 to 1000 do
Mutex.lock lock;
total := !total + 1;
Mutex.unlock lock
done
in
let first = Domain.spawn bump in
let second = Domain.spawn bump in
Domain.join first;
Domain.join second;
Printf.printf "%d\n" !totalpackage main
import (
"fmt"
"sync"
)
func main() {
var lock sync.Mutex
var waiting sync.WaitGroup
total := 0
for range 2 {
waiting.Add(1)
go func() {
defer waiting.Done()
for range 1000 {
lock.Lock()
total++
lock.Unlock()
}
}()
}
waiting.Wait()
fmt.Println(total)
}The two columns are structurally identical, and both leave it to the programmer to remember the lock — delete the locking in either and the program still compiles and quietly loses increments. Two Go details worth having:
go test -race and go run -race ship with the toolchain and reliably find exactly this bug, which is a much better safety net than OCaml offers; and the zero value of a sync.Mutex is an unlocked mutex, so var lock sync.Mutex needs no constructor. Go's own slogan is "share memory by communicating" — prefer a channel to a mutex where the shape allows.select and Timeouts
The construct that makes Go's concurrency compose, and the one with no OCaml counterpart at all.
(* No select, and no timeout primitive. Doing this in
OCaml means a library — Eio or Domainslib — or
building it from mutexes and condition variables. *)
let () =
let worker = Domain.spawn (fun () -> 42) in
Printf.printf "%d\n" (Domain.join worker)package main
import (
"fmt"
"time"
)
func main() {
result := make(chan int)
go func() { result <- 42 }()
// select waits on several channels at once, and
// time.After makes a timeout just another case.
select {
case value := <-result:
fmt.Println(value)
case <-time.After(time.Second):
fmt.Println("timed out")
}
}select blocks until one of several channel operations can proceed, picking randomly among those that are ready, and a default case makes it non-blocking. Because a timer is just a channel (time.After returns one), a timeout is an ordinary case rather than a special mechanism — and so is cancellation, since a context.Context exposes a Done() channel. That uniformity is the real design achievement: waiting, timing out and cancelling are one construct. OCaml 5 has no equivalent in the standard library; Eio provides structured concurrency with cancellation, and it is a library choice.Cancellation Travels as an Argument
Go's answer to cancellation is a value passed explicitly through every function that might block — and that convention is not optional in practice.
(* OCaml 5 effect handlers can implement cancellation
without threading a value through every signature —
the handler decides, and the code below is unaware. *)
let () =
let worker = Domain.spawn (fun () -> "finished") in
print_endline (Domain.join worker)package main
import (
"context"
"fmt"
"time"
)
// Convention: ctx is the FIRST parameter of any function
// that might block, all the way down the call chain.
func work(ctx context.Context) string {
select {
case <-time.After(10 * time.Millisecond):
return "finished"
case <-ctx.Done():
return "cancelled: " + ctx.Err().Error()
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
fmt.Println(work(ctx))
}context.Context carries a cancellation signal, a deadline and request-scoped values, and the standard library takes one in every blocking call. The cost is that it colors your function signatures exactly the way async does in other languages: adding cancellation at the bottom of a call chain means adding ctx to every function above it. OCaml 5's effect handlers are the interesting contrast — a handler can implement cancellation without the intervening functions knowing, which is precisely the coloring problem Go solves by giving in to it and Rust solves by giving in to it differently.Packages
A Package Is a Directory
Go has no module construct in the language — a package is a directory, and there is no nesting.
module Geometry = struct
let pi = 3.14159
let circle_area radius = pi *. radius *. radius
end
let () =
Printf.printf "%.4f\n" Geometry.pi;
Printf.printf "%.2f\n" (Geometry.circle_area 2.0)package main
import "fmt"
// A package is a DIRECTORY of files, not a construct you
// can write inside one. This is the same code as one
// package would hold; capitalization is the visibility.
const Pi = 3.14159
func CircleArea(radius float64) float64 { return Pi * radius * radius }
func main() {
fmt.Printf("%.4f\n", Pi)
fmt.Printf("%.2f\n", CircleArea(2))
}OCaml modules are a language construct: they nest, can be defined inline, can be constrained by a signature, passed to a functor and packed into a value. A Go package is a directory of files that share a namespace, and that is the whole of it — no nesting, no signatures, no functors. Visibility is decided by capitalization of the identifier, which is unusually blunt and unusually memorable. What Go adds instead is a strict rule against import cycles, which forces a directed dependency graph and is a real architectural benefit on a large codebase.
No Functors
For the common case the generic function is shorter; for what functors are actually for, there is no translation.
module type Comparable = sig
type t
val compare : t -> t -> int
end
module MakeLargest (Element : Comparable) = struct
let largest items =
List.fold_left
(fun best item -> if Element.compare item best > 0 then item else best)
(List.hd items) (List.tl items)
end
module IntCompare = struct
type t = int
let compare = compare
end
module LargestInt = MakeLargest (IntCompare)
let () = Printf.printf "%d\n" (LargestInt.largest [ 3; 9; 4 ])package main
import (
"cmp"
"fmt"
)
// A constrained type parameter covers the common case with
// no module to build and nothing to name.
func largest[T cmp.Ordered](items []T) T {
best := items[0]
for _, item := range items[1:] {
if item > best {
best = item
}
}
return best
}
func main() {
fmt.Println(largest([]int{3, 9, 4}))
fmt.Println(largest([]string{"fig", "apple"}))
}A functor takes a module of types and operations and produces a module. Go has no module-level parameterization at all, and the substitute is a constrained type parameter —
cmp.Ordered is a constraint listing the types that support <. That covers "one type with a standard capability" neatly. What it cannot do is bundle a type with its operations so the pairing is checked and supplied once: there is no way to say "this type comes with this comparison" and have the compiler thread it through, so an alternative ordering means passing a comparison function at every call, as slices.SortFunc does.Testing Is in the Toolchain
A small thing that changes how a codebase feels: there is nothing to choose and nothing to install.
(* Testing means an opam package — Alcotest, OUnit,
QCheck — plus a dune test stanza. The library ships
no test framework. *)
let add first second = first + second
let () =
assert (add 2 2 = 4);
print_endline "ok"package main
import "fmt"
func Add(first, second int) int { return first + second }
// In add_test.go, needing no dependency at all:
//
// func TestAdd(t *testing.T) {
// if got := Add(2, 2); got != 4 {
// t.Errorf("Add(2, 2) = %d, want 4", got)
// }
// }
//
// Then: go test ./... (which also runs go vet)
func main() {
fmt.Println("ok")
}Go ships
testing in the standard library and go test in the toolchain, with table-driven tests as the near-universal convention, benchmarks (func BenchmarkX), fuzzing (func FuzzX), coverage and the race detector all built in. Nothing is chosen and nothing is installed, so every Go codebase tests the same way. OCaml has excellent testing libraries — Alcotest, QCheck for property testing — and choosing among them is a decision each project makes, which is the same small tax the web-framework choice imposes. Note the naming rule: a test file ends in _test.go and a test function starts with Test.The Trade
What You Actually Gain
The honest case for the trade, which a page listing only losses would leave out.
(* OCaml's standard library is small on purpose. An HTTP
server means an opam package — Dream, Cohttp, Eio —
and a choice between concurrency libraries. *)
let () =
print_endline "OCaml: pick a web framework, pick a concurrency library"package main
import "fmt"
// net/http is in the standard library, is production-grade,
// and needs no dependency and no concurrency library —
// every handler already runs in its own goroutine.
//
// http.HandleFunc("/", handler)
// http.ListenAndServe(":8080", nil)
func main() {
fmt.Println("Go: net/http is already there, and so is the scheduler")
}Go's standard library contains a production HTTP server, TLS, JSON, a test framework, a race detector, a profiler and a formatter — so a service needs no dependency to exist and few to grow. Compilation is fast enough to feel instant, the output is a single static binary that copies to a container with no runtime, and the concurrency model means a request handler is just a function. Against that, OCaml gives you a far better type system and asks you to choose a web framework, choose a concurrency library, and accept that the ecosystem is smaller. Which side wins depends on whether the hard part of the problem is in the types or in the operations.
Habits Worth Keeping
The last row, and the practical one: which OCaml instincts still pay off in a language that cannot enforce them.
(* Make illegal states unrepresentable: a variant with a
payload per case means the wrong combination cannot be
constructed. *)
type connection =
| Disconnected
| Connecting of float
| Connected of { session : string; since : float }
let describe = function
| Disconnected -> "disconnected"
| Connecting since -> Printf.sprintf "connecting for %.0fs" since
| Connected { session; _ } -> Printf.sprintf "connected as %s" session
let () =
List.iter (fun state -> print_endline (describe state))
[ Disconnected; Connecting 2.0; Connected { session = "abc"; since = 0.0 } ]package main
import "fmt"
// You cannot make illegal states unrepresentable here —
// but you can make them unreachable behind a constructor
// and keep the fields unexported so nobody else can.
type Connection struct {
state string
session string
}
func Disconnected() Connection { return Connection{state: "disconnected"} }
func Connecting() Connection { return Connection{state: "connecting"} }
func Connected(id string) Connection { return Connection{state: "connected", session: id} }
func (c Connection) Describe() string {
switch c.state {
case "connected":
return "connected as " + c.session
default:
return c.state
}
}
func main() {
for _, state := range []Connection{Disconnected(), Connecting(), Connected("abc")} {
fmt.Println(state.Describe())
}
}You cannot make illegal states unrepresentable in Go — no variant, no exhaustiveness, and a struct always has every field. What you can do is keep the fields unexported and expose only constructors that produce valid values, so the illegal states are unreachable from outside the package even though the compiler cannot prove it. The same applies throughout: keep functions small and total, return errors rather than partial results, prefer values to pointers so aliasing cannot surprise you, and put the type's invariants behind its package boundary. None of it is checked. All of it still works.
The Toolchain Is the Argument
Both columns print the same line; the Go column's comment is the content.
(* OCaml's tooling is good and assembled per project:
dune, ocamlformat, merlin, odoc, Alcotest, each
chosen and configured. *)
let () = print_endline "assembled per project"package main
import "fmt"
// All of this ships with the compiler and needs no setup:
// go build go test go fmt go vet
// go doc go mod go work go run
// -race -cover pprof -bench
func main() {
fmt.Println("assembled per project")
}The race detector is the one worth singling out:
go test -race instruments memory access and reliably reports data races with both stack traces, and it ships with the compiler. OCaml has no equivalent, and its runtime guarantees only that a race cannot corrupt memory — you still get a wrong answer with nothing to point at. Add the built-in profiler, coverage, benchmarking, documentation server and dependency management, and the toolchain becomes a large part of the honest case for Go. OCaml's pieces are individually excellent and are assembled per project, which is a real if smaller cost paid on every new repository.