Let bindings
let introduces a name with two key properties: 1. Scope — the name is in scope only within the body. 2. Generalization — if the bound value has free type variables, those are quantified, and every use of the name instantiates the polytype fresh. This is "let-polymorphism".
nested lets introduce scoped names
Inferred
Env
add : Number -> Number -> Number
Source
let x = 10 in
let y = 20 in
add x y
Reading tip: let name = value in body introduces name so that body can refer to it. The name's scope is the body — outside the let, the name doesn't exist. Nesting is how you bind several names one after the other, each visible to everything to its right.
F# test body
X.Let (X.Ident "x") (X.Lit 10) (
X.Let (X.Ident "y") (X.Lit 20) (
X.App (X.App (X.Var "add") (X.Var "x")) (X.Var "y")))
|> solve
[ "add", Mono (BuiltinTypes.number ^-> BuiltinTypes.number ^-> BuiltinTypes.number) ]
None
|> shouldSolveType (Mono BuiltinTypes.number)
let-bound identity can be applied
Inferred
Source
let id = fun x -> x in
id 42
A sanity check: binding the identity function to a name and using it via that name should work just like using the lambda inline. Without this baseline holding, nothing else in Let.fs would be meaningful — let must at least preserve the type of what it binds.
F# test body
X.Let (X.Ident "id") (X.Fun (X.Ident "x") (X.Var "x")) (
X.App (X.Var "id") (X.Lit 42))
|> solve [] None
|> shouldSolveType (Mono BuiltinTypes.number)
let-polymorphism allows use at different types
Not yet implemented
Source
let id = fun x -> x in
let n = id 42 in
let s = id "hello" in
s
Inferred
String (once let-polymorphism is implemented)
Let-polymorphism means: at the moment a let binds a value, any still-free type variables become universally quantified, and every later use of the name picks its own fresh copy of the type. In plainer words — after let id = fun x -> x, the two calls id 42 and id "hello" get independent type variables, so they don't have to agree. Without this, the first use would pin id's type and the second would be rejected. NOT YET IMPLEMENTED: the inferencer currently binds a let-ident to a raw TVar (EnvItem.Internal), not to a generalized polytype, so the second use unifies with the first. See Lang.fs Expr.Let branch in generateConstraints. Env-provided polytypes (EnvItem.External) already instantiate fresh — see Polymorphism.fs.
F# test body
X.Let (X.Ident "id") (X.Fun (X.Ident "x") (X.Var "x")) (
X.Let (X.Ident "n") (X.App (X.Var "id") (X.Lit 42)) (
X.Let (X.Ident "s") (X.App (X.Var "id") (X.Lit "hello")) (
X.Var "s")))
|> solve [] None
|> shouldSolveType (Mono BuiltinTypes.string)
inner let shadows the outer binding
Inferred
Source
let x = 1 in
let x = "hello" in
x
Shadowing: a binding with the same name as an outer one hides the outer one inside its own scope. It's a reuse of the name, not a mutation — the outer x is still a Number, it's just unreachable from here. Different types on the two bindings is fine.
F# test body
X.Let (X.Ident "x") (X.Lit 1) (
X.Let (X.Ident "x") (X.Lit "hello") (
X.Var "x"))
|> solve [] None
|> shouldSolveType (Mono BuiltinTypes.string)
let-bound value keeps its polymorphic type
Inferred
Source
let identity = fun x -> x in
identity
Inferred
forall a. a -> a
Returning a polymorphic let-bound value preserves its polytype.
F# test body
X.Let (X.Ident "identity") (X.Fun (X.Ident "x") (X.Var "x")) (
X.Var "identity")
|> solve [] None
|> shouldSolveType (TDef.Generalize (%0 ^-> %0))