Let Bindings and Type Inference
The reflex to unlearn
In C#, declaring a value almost always means writing or letting the compiler guess an explicit type: int total = 42;, or var total = 42; if you let the compiler infer it locally — but var remains a one-off exception, reserved for local initializations, and a method signature is always fully typed: int Add(int a, int b) { return a + b; }.
In F#, there's only one keyword to bind a name to something, whether it's a value or a function: let. And type inference isn't a local exception, it's the default mode of operation, including for a function's parameters and return type.
let for a value
let total = 42
Compared to int total = 42; in C#, the difference is obvious: no type written anywhere. The F# compiler looks at the literal 42, infers it's an int, and fixes the type of total accordingly — just as strictly as if you'd written it yourself. F# is a statically typed language: the type not being visible in the code doesn't mean it doesn't exist or could change later.
If you launch dotnet fsi and type:
let total = 42;;
fsi confirms the inferred type:
val total: int = 42
let for a function
The same construct is used to define a function — there's no separate keyword like void or a return type to write before the name:
let add a b = a + b
In fsi:
let add a b = a + b;;
Expected output:
val add: a: int -> b: int -> int
That repeated -> isn't a typo: add is actually a function with a single parameter (a) that returns another function with a single parameter (b), which finally returns an int. That's the consequence of currying, a mechanism this path doesn't need to dig into right now — just remember that add a b is used exactly like a two-parameter C# method: add 2 3 returns 5, just as naturally as Add(2, 3).
Compare with the C# equivalent, where the type of each parameter and the return type must be written explicitly in the signature:
int Add(int a, int b) => a + b;
Where the inferred type comes from: the function body
The compiler doesn't guess at random: for add a b = a + b, the + operator applied to a and b only makes sense here between two numbers, and with no other clue, F# defaults to int. The return type, meanwhile, is always determined by the last expression evaluated in the function body — here, a + b, hence int.
This means an F# function's return type never has to be announced ahead of time like in C#: it follows mechanically from what the function ends up computing.
Annotating a type explicitly when it's useful
Inference doesn't forbid writing a type — it just makes it optional. The following two syntaxes are valid and strictly equivalent as far as the compiler is concerned:
let increment x = x + 1
let increment (x: int) : int = x + 1
One case where the annotation becomes necessary: when the function body uses a member of a specific type, such as a .NET method, and there's nothing else in the code indicating what type it is:
let toLower (s: string) = s.ToLower()
Without the : string, the compiler would have no way of knowing that s has a ToLower method — the annotation removes the ambiguity, exactly like an explicit parameter type would in C#.
A useful consequence: automatic generalization
When nothing in a function's body forces a specific type, F# makes the function generic on its own, without you having to write <T> anywhere:
let makeTuple a b = (a, b)
In fsi:
val makeTuple: a: 'a -> b: 'b -> 'a * 'b
'a and 'b are generic type parameters (the F# equivalent of the T in a generic C# method). makeTuple therefore works with integers, strings, or any mix of the two, with no extra version to write — a behavior you'd only get in C# by explicitly writing (T1, T2) MakeTuple<T1, T2>(T1 a, T2 b).
Exercise — writing and running let bindings
Open dotnet fsi and run, one line at a time, each of these three definitions:
let price = 19.99;;
let greeting name = "Hello, " + name;;
let double x = x * 2;;
Expected outputs, in order:
val price: float = 19.99
val greeting: name: string -> string
val double: x: int -> int
Classic trap for a C# developer: F#'s float is not C#'s float. price here is a 64-bit floating-point number, exactly what C# calls double — it's the type F# defaults to for a decimal literal like 19.99. The actual equivalent of C#'s float (32-bit) is called float32 in F#, and it must be written explicitly (19.99f) to get it.
Now verify these inferences by calling each function:
greeting "Alice";;
double 21;;
Expected outputs:
val it: string = "Hello, Alice"
val it: int = 42
If your inferred types differ from the ones above, re-read the body of the function in question: the displayed type always follows directly from what that last expression does, never from an arbitrary convention.
What you just did
You used let to bind values and functions, observed how F# systematically infers their types from the body of the expression rather than from an explicit declaration, and saw that a type annotation remains possible — and sometimes necessary — when the compiler doesn't have enough information to decide on its own.
In the next module, you'll test a limit of what let lets you do: trying to reassign an already-bound value, and understanding why F# refuses what C# allows by default.
Check your understanding
Without running it, what type will fsi display for `let greeting name = "Hello, " + name`?
Why does `let toLower (s: string) = s.ToLower()` need an explicit annotation, while `let double x = x * 2` does perfectly well without one?
`let makeTuple a b = (a, b)` is declared with no annotation at all. How does its inferred type, 'a -> 'b -> 'a * 'b, differ from what C# would need to do to get equivalent behavior?
Want to hear about the next modules?
The Academy stays free and open-access, no sign-up required. If you'd just like to be notified by email when a new module ships, here you go — no obligation, unsubscribe anytime with one click.