100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Programming

Type Inference in F#

See how F#'s Hindley-Milner-based type inference deduces precise types without annotations, and when explicit annotations are still needed.

Core ConceptsIntermediate8 min readJul 10, 2026
Analogies

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.

fsharp
// 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 restriction

Order 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

Was this page helpful?

Topics covered

#Programming#FStudyNotes#TypeInferenceInF#Type#Inference#Works#Hindley#StudyNotes#SkillVeris#ExamPrep