jonnifyfsharp

Dive C3.02.1

Type inference — you rarely annotate

F# is statically typed. You write the code; the compiler writes the types.

An Elixir developer is used to writing no types at all — a value carries its type at run time, and a function takes whatever it is handed. F# is statically typed, so every value has a type before the program runs. The surprise is that you still write almost no types. The Hindley–Milner inference algorithm derives each type from how the value is used: a + on integers makes a parameter int, a string concatenation makes it string, and a value used no specific way stays generic. The annotations an Elixir developer fears never arrive — what arrives is a type the compiler tracks and checks, with no extra typing.

C3.02.1 · part 1

The same function, two regimes

In Elixir, add takes anything and the meaning of + is settled when the call runs — integers add, and the same operator concatenates a binary. Nothing is decided until the value arrives.

# Elixir — no types written, no types inferred; checked at run time
def add(a, b), do: a + b
add(3, 4)        # => 7
add("a", "b")    # => "ab"  (a different + at run time)

In F#, the same definition is given a type before it runs. The + defaults to int, so inference settles on int, and the signature add reports back is exact — with not one type written by hand.

let add a b = a + b
// val add: a: int -> b: int -> int
add 3 4
// val it: int = 7
// add "a" "b" does not type-check — the type is already int
inference
the compiler works out each type from how the value is used; you write the type only when it cannot.
Hindley–Milner
the algorithm behind it (the same one the ML family in C0.1 introduced), which finds the most general type that fits.
vs. Elixir
Elixir checks shape at run time with the value present; F# fixes the type at compile time, before any value arrives.
C3.02.1 · part 2

No annotations, even for tuples and generics

The same deriving-from-use settles every shape. A string concatenation pins string; a pair literal infers a tuple type; and a function that uses its input in no specific way stays generic — 'a, 'b — which is the typed form of an Elixir function that works for any term. Each FSI echo below was produced by dotnet fsi.

let greet name = "Hello, " + name
// val greet: name: string -> string

let pair x = (x, x + 1)
// val pair: x: int -> int * int

let swap (a, b) = (b, a)
// val swap: a: 'a * b: 'b -> 'b * 'a       — generic, any pair

let firsts xs = List.map fst xs
// val firsts: xs: ('a * 'b) list -> 'a list  — generic over the element

swap and firsts reached no concrete type, so inference kept them general: they hold for any 'a and 'b. This is the type-level mirror of Elixir's "works for any term" — the difference is that F# wrote the generality down and will check every call against it.

Infer a type

Pick an F# expression. The diagram sends it through inference to the static type the compiler settles on — the exact val … signature dotnet fsi echoes. No type was written in any of them; each was derived from how the value is used. A blue type is concrete; a violet one is generic.

expression · inference · static type

expression inference static type let add a b = a + b Hindley–Milner type derived from use int -> int -> int
let add a b = a + b · inferred · val add: a: int -> b: int -> int · concrete, no annotation written
Without JavaScript: the same five, inferred by FSI
expressioninferred type (val …)
let add a b = a + ba: int -> b: int -> int
let greet name = "Hi, " + namename: string -> string
let pair x = (x, x + 1)x: int -> int * int
let swap (a, b) = (b, a)a: 'a * b: 'b -> 'b * 'a
let firsts xs = List.map fst xsxs: ('a * 'b) list -> 'a list

The type is not something you add to F#. It is something F# reads off the code you already wrote — and then checks every call against, before the program runs.

C3.02.1 · part 3

The one place you do annotate

Inference is near-total, with one well-known exception: a bare generic value — not a function — that nothing uses to pin its type. F# will not generalise it on its own, and reports the value restriction, FS0030. The fix is the rare annotation, which names the type the value should hold.

// bare and too polymorphic — F# reports FS0030, the value restriction
let empties = []
// the fix: annotate the element type
let okList : Result<int, string> list = []
// val okList: Result<int,string> list = []
This is the trap that catches every newcomer once. It is not inference failing — it is inference refusing to guess a generalisation that could be unsound. Annotate the value, and the type is fixed; this is the rare line where you write a type in F#.

Elixir

No types, ever — a value's shape is whatever the run-time value is, checked by the match in front of it when the call runs.

F#

A static type for every value, inferred from use — written by hand only at the value-restriction edge.

Where this leads

Inference gives a type to everything. The next dive uses that to model a domain so the wrong shape has no value at all — the discriminated union that makes illegal states unrepresentable, where Elixir's {:paid, amount} and :unpaid were a convention. The dive after weighs what this costs against what Elixir's dynamism gave.

References

Sources

Related in this course