jonnifyfsharp

Module C3.02

From dynamic to static types

The one part of F# that is genuinely new coming from Elixir.

Most of what an Elixir developer knows carries straight over to F# — immutability, pattern matching, the |> pipe. The type system is the exception. In Elixir, a value carries its type at run time, and the shape of a function's input is checked by a pattern match when the call runs: def add(a, b), do: a + b takes anything. In F#, every value has a static type — but you rarely write one. The Hindley–Milner inference algorithm derives the type from how a value is used, and fixes it before the program runs: let add a b = a + b is int -> int -> int, checked. This module takes that shift in three dives — inference, illegal states made unrepresentable, and an even-handed look at the trade.

Run time, or compile time

The difference is one of when. Elixir settles a value's type when the code runs, with the value in hand; F# settles it when the code compiles, with a type it inferred. Both languages check shape — F# moves the check earlier, and into the type. The three dives below take that one at a time, then weigh the cost on both sides.

The first dive is type inference: F# is statically typed, yet you annotate almost nothing, because inference works the type out from use. The second is making illegal states unrepresentable: where Elixir models variants with tagged tuples and atoms held together by convention, an F# discriminated union ties data so the nonsense state has no value to build. The third is the trade — what the static types buy, and what Elixir's dynamism gave in return.

The three dives · select one

C3.02.1 type inference C3.02.2 illegal states C3.02.3 the trade inferred modelled weighed
C3.02.1 · Type inference

F# is statically typed, yet you rarely annotate: let add a b = a + b infers val add: a: int -> b: int -> int. Where Elixir checks shape at run time, the type is settled at compile time.

You learn how F# infers a static type with no annotation

Open this dive · or pick another above

The shift is not "now you write types". It is that the type is settled before the program runs, and the compiler reads off it the checks Elixir would have run later.

The shift, in one line each

The contrast is concrete. Where Elixir takes any term and checks it when the call runs, F# infers a type and checks it when the code compiles. Where Elixir tags a tuple by convention, F# ties the data into a union case the compiler enforces. The same two examples run through all three dives.

Elixir — a value carries its type at run time

# Elixir — checked when it runs, not before
def add(a, b), do: a + b      # takes anything; shape settled at run time
{:paid, 42}                   # a tagged tuple; the tag is a convention
:pending                      # an atom; one of a set held by discipline

F# — the type is inferred, and settled at compile time

let add a b = a + b
// val add: a: int -> b: int -> int

type Payment = Unpaid | Paid of decimal
// an "amount" without "paid" has no value to build

Elixir

Shape is checked when the code runs, by the pattern match in front of the value — a tag or an atom carries meaning the code agrees to honour.

F#

Shape is checked when the code compiles, against a type the compiler inferred — the meaning is in the type, and a state outside it cannot be written.

The three dives

Read them in order. Each stands on valid F# beside real, idiomatic Elixir: every // val echo below was produced by dotnet fsi, and the Elixir is the everyday form — tagged tuples, atoms, and the case match. Together they take an Elixir developer from "shape is a run-time match" to "shape is an inferred, enforced type", and weigh what each regime costs.

live module hub built dive ready soon specified

References

Sources

Related in this course