How Type Inference Works: Hindley-Milner in F#
F# infers types using an algorithm rooted in Hindley-Milner type inference, examining how a value is used throughout a function body to deduce the most general type consistent with every usage, without requiring the programmer to write type annotations. Given "let double x = x + x", the compiler sees + applied to x and defaults numeric operators to int, so it infers double : int -> int even though no type was written anywhere in the definition.
Cricket analogy: A talent scout doesn't ask a young batter to declare their role on paper -- they infer 'opener' purely from watching how the batter plays the new ball, the same way F# infers double's type purely from watching how x is used with +.
Automatic Generalization
When a function's body places no constraints that pin its parameter to a specific type, F# generalizes it into a generic function automatically -- "let identity x = x" becomes identity : 'a -> 'a, usable with an int, a string, or any other type at each call site. This automatic generalization means you get parametric polymorphism, similar to generics in C# or Java, without ever writing an angle-bracket type parameter yourself.
Cricket analogy: A genuinely all-format player like Jos Buttler can slot into T20, ODI, or Test cricket without changing his technique, the way identity : 'a -> 'a works identically whether called with an int, a string, or any other type.
// No annotations anywhere -- every type below is inferred
let double x = x + x // int -> int (defaults to int)
let identity x = x // 'a -> 'a (generic)
let addStrings a b = a + " " + b // string -> string -> string
// Inference flows through a pipeline automatically
let total =
[1; 2; 3; 4]
|> List.map (fun x -> x * 2) // int list -> int list
|> List.sum // int list -> int
// The "value restriction" forces an explicit type or eta-expansion here
let emptyList : int list = [] // annotation needed at module scope
// let emptyList = [] // would trigger FS0030 value restrictionOrder Matters: Left-to-Right, Top-to-Bottom Inference
Type inference in F# proceeds through the file roughly top-to-bottom and left-to-right, meaning a function's usage later in the same file cannot influence how an earlier definition is inferred -- this is why annotating a helper function used only once, deep in a large file, can sometimes resolve a confusing inference error at its call site rather than at its definition. Because of this directional flow, moving a function definition below its first use, or vice versa, can occasionally change what the compiler is able to infer.
Cricket analogy: A batting collapse is analyzed ball-by-ball in the exact order deliveries were bowled -- you can't reinterpret an early dismissal based on what happens later in the innings -- mirroring how F# infers a definition based only on code that appears before it, not after.
The 'value restriction' (warning/error FS0030) blocks generalization of certain non-function bindings -- most commonly an empty collection literal like let emptyList = [] at module scope -- because F# cannot safely infer a fully generic type for a value rather than a function. Fix it by adding an explicit type annotation or by turning the binding into a function, e.g. let emptyList () = [].
When Explicit Annotations Are Required
Certain situations force you to write explicit annotations: public API boundaries benefit from annotations for documentation and stability even when inference would succeed, overloaded .NET methods sometimes need a type hint to pick the right overload (e.g., (x: int) to select int over float overloads), and the 'value restriction' -- where F# refuses to generalize certain non-function values like empty list literals bound at module scope -- requires either an annotation or reformulating the binding as a function.
Cricket analogy: Even a naturally gifted batter still works with a specialist throwdown coach before a big series to fine-tune specific technical details, mirroring how F# code that could infer fine still benefits from explicit annotations at public boundaries for clarity.
Type Inference Across Pipelines
Inference threads smoothly through pipe-operator chains because each stage's output type becomes the next stage's input type automatically: in "[1;2;3] |> List.map (fun x -> x * 2) |> List.sum", the compiler infers List.map's lambda operates on int from the list literal, and that List.sum then receives an int list, without any annotation appearing in the chain. This is why F# pipelines read cleanly -- the types propagate invisibly alongside the values.
Cricket analogy: A fielding drill that passes the ball through a fixed relay -- keeper to mid-wicket to bowler -- where each throw's target is fixed by the drill's design, mirrors how each stage of a |> pipeline has its input type fixed by the previous stage's output.
When calling an overloaded .NET method, such as System.Convert.ToInt32, F# may not be able to pick the right overload purely from context; adding a parameter annotation like (s: string) or an explicit cast steers inference toward the intended overload without abandoning type safety.
- F# uses Hindley-Milner-style inference to deduce types from how values are used, no annotations required.
- Functions with no type-pinning operations are generalized automatically into generic functions ('a -> 'a).
- Inference proceeds top-to-bottom, left-to-right through a file, so definition order can affect what's inferable.
- Public APIs, overload resolution, and the value restriction are common cases needing explicit annotations.
- The value restriction (FS0030) blocks generalizing certain non-function values like empty list literals.
- Types propagate automatically through |> pipelines without any annotations in the chain.
- Numeric literals default to int unless context forces a different numeric type.
Practice what you learned
1. What type does F# infer for let identity x = x?
2. What does the 'value restriction' primarily affect?
3. In what order does F# type inference generally proceed through a file?
4. Why might you still add an explicit type annotation even when F# can infer the type?
5. What is the inferred type of let addStrings a b = a + " " + b?
Was this page helpful?
You May Also Like
Functions in F#
Learn how F# treats functions as first-class values, covering let bindings, currying, partial application, and higher-order functions.
Pattern Matching in F#
Understand how F#'s match expression destructures values, enforces exhaustiveness, and replaces verbose conditional logic with concise, type-safe branching.
Tuples and Records
Learn F#'s two core structural product types -- quick, unnamed tuples and named, documented records -- and when to reach for each.
Related Reading
Related Study Notes in Programming
Browse all study notesApache Spark Study Notes
Programming · 30 topics
ProgrammingApache Flink Study Notes
Programming · 30 topics
ProgrammingHadoop Study Notes
Programming · 30 topics
ProgrammingSnowflake Study Notes
Programming · 30 topics
ProgrammingApache Airflow Study Notes
Programming · 30 topics
Programmingdbt (Data Build Tool) Study Notes
Programming · 30 topics