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.
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.
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 | inferred type (val …) |
|---|---|
| let add a b = a + b | a: int -> b: int -> int |
| let greet name = "Hi, " + name | name: 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 xs | xs: ('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.
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 = []
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
- Microsoft — F# documentation — type inference and the automatic generalization guidance.
- F# Core Library reference —
List.map,fst, and the core types inference settles on. - The F# Software Foundation — the language's home and the ML lineage behind Hindley–Milner.
Related in this course
- C3.02.2 — Making illegal states unrepresentable — what the inferred type lets you model.
- Elixir F3.01 — Values, types & IEx — how the BEAM types a value, at run time.
- C3 — F# for Elixir developers — the chapter this dive sits in.