Hindley-milner type system demystified

2025-10-13 hindley-milner, type-system, haskell
a guide to hindley-milner type system: types, unification & algorithm w with haskell implementation

setup

a hindley-milner type system is a type-system for λcalculus with parametric polymorphism (let statements).

hm type system is complete & has the ability to infer the most general type of a given program without programmer-supplied type annotations or other hints.

the prose is divided into 3 acts: type system, substitution/unification & algow. 1


import           Control.Monad        ( replicateM )
import           Control.Monad.Except
import           Control.Monad.State

import qualified Data.Map             as Map
import           Data.Maybe           ( isJust )
import qualified Data.Set             as Set

hindley-milner type system

we (not so) formally describe the type system by syntax rules in this act.

syntax

a type variable (α) is a placeholder symbol that stands for an unknown type which may later be instantiated with any concrete type (int, string, etc.) once more information is available through type inference.


type TyVar = String

expressions

the expressions (term-level language) to be typed are exactly those of the λcalculus extended with a let-expression.


data Expr
   = Var TyVar             -- variable         (x)
   | App Expr Expr         -- application      (e1e2)
   | Abs TyVar Expr        -- abstraction      (λx.e)
   | Let TyVar Expr Expr   -- let-polymorphism (let x = e1 in e2)
   deriving Show

the application is left-binding and binds stronger than abstraction or the let-in construct.

monotypes

a monotype (τ) is either a type variable or an application of a type constructor to a list of monotypes.


data Monotype
   = TyVar' TyVar           -- type variable
   | TyCon' String [Mono]   -- e.g. TyCon' "Int" [], TyCon' "String" [], TyCon' "List" [Mono, Mono]

but for the sake of simplicity, we explicitly abstract the int/string constant types & the function type to illustrate the type inference effectively:


data Mono
   = TyVar TyVar       -- variable
   | TyInt Int         -- primitive int type
   | TyStr String      -- primitive string type
   | TyFun Mono Mono   -- primitive application type
   deriving Eq         -- two monotypes are equal if they have identical terms

-- custom Show instance for readable type display
instance Show Mono where
   show (TyVar v) = v
   show (TyInt _) = "Int"
   show (TyStr _) = "String"
   show (TyFun t1 t2) = showFunArg t1 ++ " -> " ++ show t2
      where
         -- add parentheses around function types on the left of arrow
         showFunArg f@(TyFun _ _) = "(" ++ show f ++ ")"
         showFunArg t             = show t

polytypes (type schemes)

polytypes are types containing variables bound by zero or more for-all quantifiers, e.g. ∀α.α → α.

for instance, a function with polytype ∀α.α → α can map any value of the same type to itself. the identity function is a value for this type.

quantifiers can only appear top level. for instance, a type ∀α.α → ∀α.α is excluded by the syntax of types. also monotypes are included in the polytypes, thus a type has the general form ∀α₁… ∀αₙ.τ, where n ≥ 0 and τ is a monotype. 2


data Poly
   = Mono Mono
   | Forall [TyVar] Mono deriving Show

context & typing

to meaningfully bring together the expressions & types, a third part context is needed.

syntactically, a context is a map from type variables to polytypes. each item states that typevar xᵢ has the type σᵢ.

context, expression & polytypes combined give a typing judgment of the form Γ ⊢ e : σ, stating that under the context (assumptions) Γ, the expression e has type σ.

later in the context of type inference, we can also interpret a typing judgment as: we infer the type of expression e as σ under the assumptions Γ.


type Context = Map.Map TyVar Poly
data Judgement = Judgement Context Expr Poly deriving Show

free type variables

free type variables are those not bound by a quantifier (∀).

intuitively, free type variables are unknown types that still need to be determined during type inference, while bound variables are generic placeholders for polymorphism.


class Free a where
   ftv :: a -> Set.Set TyVar

ftv for monotypes

the implementation collects all type variables appearing in a monotype:

TyVar v: the variable itself is free → {v}

TyFun a b: union of free variables from both sides → ftv(a) ∪ ftv(b)

primitives (TyInt, TyStr): no type variables → {}

example: ftv(α → (β → Int)) = {α, β}


instance Free Mono where
   ftv (TyVar v)   = Set.singleton v
   ftv (TyFun a b) = (ftv a) <> (ftv b)
   ftv _           = mempty

ftv for polytypes

the implementation extracts free variables by excluding those bound by ∀ quantifiers:

Mono m: no quantifiers, just delegate to monotype ftv

Forall vars ty: compute ftv(ty) then remove quantified vars → ftv(ty) \ vars

example: ftv(∀α.α → β) = ftv(α → β) \ {α} = {α, β} \ {α} = {β}

intuitively, bound variables represent generic polymorphism (known abstraction), while free variables represent unknowns that need to be solved during inference.


instance Free Poly where
   ftv (Mono m)         = ftv m
   ftv (Forall vars ty) = Set.difference (ftv ty) (Set.fromList vars)

ftv for contexts

the implementation unions all free variables from every polytype in the context. context maps typevars to polytypes, so we extract values, compute ftv on each, then union all results.

example: ftv({x : ∀α.α, y : β → γ, z : Int}) = {} ∪ {β, γ} ∪ {} = {β, γ}

intuitively, this tells us which type variables are in scope but not yet fully determined.


instance Free Context where
   ftv ctx = foldMap ftv (Map.elems ctx)

substitution/unification

substitution

a substitution is a mapping from type variables to monotypes. it represents a partial solution to type constraints discovered during inference.


-- examples:
-- [α ↦ Int]               -- maps α to Int
-- [α ↦ β → γ]             -- maps α to a function type
-- [α ↦ Int, β ↦ String]   -- maps multiple variables
type Subst = Map.Map TyVar Mono

we define a Substitutable typeclass with a single function apply here.


class Substitutable a where
   apply :: Subst -> a -> a

apply for monotypes

the implementation recursively replaces type variables according to the substitution:

TyVar v: look up v in substitution s

if found → return mapped type

if not found → return original variable (identity)

TyFun l r: apply substitution recursively to both sides

primitives: unchanged (no variables to substitute)

example: apply [α ↦ Int, β ↦ String] (α → β) = Int → String


instance Substitutable Mono where
    apply s t@(TyVar v) = Map.findWithDefault t v s
    apply s (TyFun l r) = TyFun (apply s l) (apply s r)
    apply _ t           = t

apply for polytypes

the implementation applies substitution while respecting quantifier scope:

Mono m: no quantifiers, just delegates to monotype apply

Forall vs m: bound variables (vs) must not be substituted

remove bound variables from substitution before applying (foldr Map.delete s vs)

this prevents capture (shadowing bound vars)

example: apply [α ↦ Int, β ↦ String] (∀α.α → β) = ∀α.α → String (α not substituted, β substituted)


instance Substitutable Poly where
    apply s (Mono m)      = Mono (apply s m)
    apply s (Forall vs m) = Forall vs (apply (foldr Map.delete s vs) m)

apply for contexts

the implementation applies substitution to every polytype in the context. since context is a map from typevars to polytypes, we use fmap/(<$>) to apply the substitution to each value.

this is crucial for propagating type information through the environment as inference proceeds.

example: apply [α ↦ Int] {x : α, y : α → β} = {x : Int, y : Int → β}


instance Substitutable Context where
    apply s = (<$>) (apply s)

composing substitutions

we categorically want to implement composition for our substitution monoid.

with compose :: Subst -> Subst -> Subst, we formulate a monoid for Subst where:

identity element is the empty substitution []

associative binary operation is compose (∘)

specifically, compose s1 s2 applies s1 "after" s2, i.e. (s1 ∘ s2)(α) = s1(s2(α)). right substitution (s2) is applied first, then left (s1) refines the result.


compose :: Subst -> Subst -> Subst
compose s1 s2 = (apply s1) <$> s2 <> s1

unification

unification is the process of finding a substitution that makes two types equal.

we'll implement robinson's unification algorithm, which produces the most general unifier (mgu).

occurs check

we need to check if a type variable occurs within a monotype. this prevents creating infinite/recursive types like α = α → β.


-- examples:
-- occursCheck α α       = True   (α occurs in α)
-- occursCheck α (α → β) = True   (α occurs in function)
-- occursCheck α (β → γ) = False  (α doesn't occur)
-- occursCheck α Int     = False  (no variables in primitives)
occursCheck :: TyVar -> Mono -> Bool
occursCheck v (TyVar v')  = v == v'
occursCheck v (TyFun l r) = occursCheck v l || occursCheck v r
occursCheck _ (TyInt _)   = False
occursCheck _ (TyStr _)   = False

the unification monad

unification can fail, so we need a monad that supports error handling: UnifyM = Except String provides exactly this capability.

unification fails when: = structural mismatch: trying to unify incompatible types (Int vs String) = occurs check failure: would create infinite types (α vs α → β)

the Except monad gives us:

automatic error propagation (if any step fails, whole computation fails)

explicit error messages via throwError


type UnifyM = Except String

variable binding

the bind function creates a minimal substitution ([v ↦ t]) from a typevar v to a monotype t, but only if it's safe.

three cases could happen:

reflexive: t == TyVar v → return [] (identity, maximally general)

occurs: v occurs in t → error (would create infinite type)

valid: v doesn't occur in t → return [v ↦ t] (minimal binding)

the occurs check is essential: without it, unifying α with (α → β) would create an infinite type: α = α → β = (α → β) → β = ...


-- examples:
-- bind α α             = []                 (identity)
-- bind α Int           = [α ↦ Int]          (valid binding)
-- bind α (α → β)       = error              (occurs check fails)
-- bind α (β → γ)       = [α ↦ β → γ]        (valid binding)
bind :: TyVar -> Mono -> UnifyM Subst
bind v t
  | t == TyVar v     = return mempty
  | occursCheck v t  = throwError ("Occurs check fails: " ++ v ++ " in " ++ show t)
  | otherwise        = return $ Map.singleton v t

most general unifier (mgu)

the mgu has the property that for any other unifier σ', there exists another unifier θ such that: σ' = θ ∘ mgu(t1, t2). 3

unification proceeds by structural recursion on type shapes, with four cases:

case 1: type variables

the unification delegates to bind which performs occurs check and creates minimal substitution [v ↦ t]:

unify α t → bind α t → [α ↦ t] (or error if occurs check fails)

this case is symmetry: unify α β and unify β α both produce valid mgu's.

example 1: unify α Int
  result = [α ↦ Int]    (bind α to Int)

example 2: unify α β
  result = [α ↦ β]      (bind α to β, arbitrary choice)

example 3: unify α (β → γ)
  check: α does not occur in (β → γ) ✓
  result = [α ↦ β → γ]

example 4: unify α (α → Int)
  check: α occurs in (α → Int) ✗
  result = error       (would create infinite type)

case 2: primitives

concrete types must match exactly. no variables to solve, so:

identical types return empty substitution [] (types already equal)

different types return error (incompatible)

example 1: unify Int Int
  result = []           (already equal, no substitution needed)

example 2: unify String String
  result = []

example 3: unify Int String
  result = error        (incompatible primitive types)

case 3: function types

the unification structurally decomposes and recursively unifys components:

unify left sides: s1 = unify l1 l2

apply s1 to right sides before unifying them (propagate constraints)

unify rights: s2 = unify (s1 r1) (s1 r2)

compose: return s2 ∘ s1

example 1: unify (α → β) (Int → String)
  step 1: s1 = unify α Int           = [α ↦ Int]
  step 2: apply s1 to rights: β, String (no change, β not in s1)
  step 3: s2 = unify β String        = [β ↦ String]
  step 4: result = s2 ∘ s1           = [α ↦ Int, β ↦ String]

example 2: unify (α → α) (Int → β)
  step 1: s1 = unify α Int           = [α ↦ Int]
  step 2: apply s1 to rights: s1(α) = Int, β
  step 3: s2 = unify Int β           = [β ↦ Int]
  step 4: result = s2 ∘ s1           = [α ↦ Int, β ↦ Int]

example 3: unify (α → β) (γ → δ)
  step 1: s1 = unify α γ             = [α ↦ γ]
  step 2: apply s1 to rights: β, δ   (no change)
  step 3: s2 = unify β δ             = [β ↦ δ]
  step 4: result = s2 ∘ s1           = [α ↦ γ, β ↦ δ]

example 4: unify ((α → β) → γ) ((Int → String) → Bool)
  step 1: s1 = unify (α → β) (Int → String)
          s1a = unify α Int          = [α ↦ Int]
          s1b = unify β String       = [β ↦ String]
          s1 = [α ↦ Int, β ↦ String]
  step 2: apply s1 to rights: γ, Bool (no change)
  step 3: s2 = unify γ Bool          = [γ ↦ Bool]
  step 4: result = s2 ∘ s1           = [α ↦ Int, β ↦ String, γ ↦ Bool]

case 4: incompatible structures

if none of the above patterns match, types are fundamentally incompatible.

example 1: unify Int (α → β)
  result = error        (primitive vs function type)

example 2: unify (α → β) String
  result = error        (function type vs primitive)

unify :: Mono -> Mono -> UnifyM Subst
unify (TyVar v) t = bind v t
unify t (TyVar v) = bind v t
unify (TyInt n) (TyInt m)
  | n == m    = return mempty
  | otherwise = throwError ("Type mismatch: " ++ show n ++ " vs " ++ show m)
unify (TyStr x) (TyStr y)
  | x == y    = return mempty
  | otherwise = throwError ("Type mismatch: " ++ show x ++ " vs " ++ show y)
unify (TyFun l1 r1) (TyFun l2 r2) = do
  s1 <- unify l1 l2
  s2 <- unify (apply s1 r1) (apply s1 r2)
  return $ compose s2 s1
unify t1 t2 =
  throwError $ "Type mismatch: " ++ show t1 ++ " vs " ++ show t2

algorithm w

algorithm w is a sound & complete type inference algorithm for the hm type system.

given a context and an expression, it either:

succeeds: returns a substitution and the inferred type

fails: returns an error message

inference monad

type inference needs two computational effects:

fresh variable generation: to create new type variables during inference

error handling: to report type errors

the InferM = ExceptT String (State Int) monad combines both:

State Int: maintains a counter for generating unique type variables (t0, t1, t2, ...)

ExceptT String: wraps State to add error handling

this gives us access to:

fresh: generates new type variables

throwError: reports type errors


type InferM = ExceptT String (State Int)
--            ^^^^^^^^^^^^^^ ^^^^^^^^^^^
--            |              |
--            |              inner: state for fresh vars
--            outer: error handling

we define a function to lift unification from UnifyM into InferM. this bridges the gap between the two monads so we can use unify inside inferW.


liftUnify :: Mono -> Mono -> InferM Subst
liftUnify a b = liftEither (runExcept (unify a b))

fresh type variables

we need to generate unique type variables: t0, t1, t2, ... during a type inference process.

this is used when:

inferring function application: need a fresh result type

inferring abstraction: parameter type is initially unknown


fresh :: InferM TyVar
fresh = do
  n <- get
  put (n+1)
  return ("t" ++ show n)

-- generates k fresh type variables at once.
freshN :: Int -> InferM [TyVar]
freshN k = replicateM k fresh

generalization

generalization turns a monotype into a polytype by quantifying free type variables, where only variables not in the context are generalized:

generalize ctx t = ∀(ftv(t) \ ftv(ctx)).t

intuitively, we only generalize variables we own (introduced locally), not variables from outer scope (already in context).


-- examples:
-- generalize {} (α → α)      = ∀α.α → α   (α not in context)
-- generalize {x:β} (α → β)   = ∀α.α → β   (only α generalized, β in context)
-- generalize {x:α} (α → α)   = α → α      (no generalization, α in context)
generalize :: Context -> Mono -> Poly
generalize ctx t =
  let vars = Set.toList $ Set.difference (ftv t) (ftv ctx)
  in if null vars then Mono t else Forall vars t

instantiation

instantiation turns a polytype into a monotype by replacing quantified variables with fresh ones. this allows using polymorphic values at different types.

by the means of fresh variables, each use of a polymorphic value gets independent types.

example: let id = λx.x in (id 5, id "hi")

first use: instantiate (∀α.α → α) → t0 → t0, then unify t0 with Int

second use: instantiate (∀α.α → α) → t1 → t1, then unify t1 with String

if we reused the same α, the two uses would conflict.


-- examples:
-- instantiate (∀α.α → α)      → t0 → t0   (fresh t0)
-- instantiate (∀α.∀β.α → β)   → t0 → t1   (fresh t0, t1)
-- instantiate (α → β)         → α  → β    (no quantifiers, return as is)
instantiate :: Poly -> InferM Mono
instantiate (Mono m) = return m
instantiate (Forall vars m) = do
  freshVars <- mapM (const fresh) vars
  let s = Map.fromList $ zip vars (TyVar <$> freshVars)
  return $ apply s m

type inference algorithm

with the type signature: inferW :: Context -> Expr -> InferM (Subst, Mono)

given a context Γ and an expression e, the inference algo returns (both):

substitution s: constraints discovered during inference

monotype t: the inferred type

essentially, the algorithm implements these four typing rules:

variable rule

  x : σ ∈ Γ   τ = instantiate(σ)
  --------------------------------   [VAR]
  Γ ⊢ x : τ, ∅

the algo look up typevar x in context Γ, instantiate its polytype with fresh variables. then return empty substitution (∅) since no constraints are generated.

example: Γ = {id : ∀α.α → α}, infer (id)
  x = "id"
  σ = lookup "id" in Γ  → ∀α.α → α
  τ = instantiate(σ)    → t0 → t0    (fresh t0)
  return (∅, t0 → t0)

application rule

  Γ ⊢ e1 : τ1, S1   S1Γ ⊢ e2 : τ2, S2
  τ' = fresh        S3 = mgu(S2τ1, τ2 → τ')
  --------------------------------------------   [APP]
  Γ ⊢ e1e2 : S3τ', S3S2S1

the algo: = infer e1 under Γ, get S1 and τ1 = apply S1 to context before inferring e2 = infer e2 under S1Γ, get S2 and τ2 = create fresh result type τ' = apply S2 to τ1 before unifying (propagate e2's constraints to e1's type) = unify S2τ1 with τ2 → τ', get S3 = return S3S2S1 (right-to-left composition) and S3τ'

example: infer ((λx.x) 5)
  step 1: infer (λx.x) → S1 = ∅, τ1 = t0 → t0
  step 2: infer 5 in S1Γ = Γ → S2 = ∅, τ2 = Int
  step 3: fresh → τ' = t1
  step 4: S2τ1 = t0 → t0
  step 5: mgu(t0 → t0, Int → t1) → S3 = [t0 ↦ Int, t1 ↦ Int]
  step 6: S3τ' = S3(t1) = Int
  step 7: return (S3S2S1 = S3, Int)

abstraction rule

  τ = fresh   Γ, x : τ ⊢ e : τ', S
  -----------------------------------   [ABS]
  Γ ⊢ λx.e : Sτ → τ', S

the algo: = create fresh type variable τ for parameter x = extend context: Γ' = Γ, x : τ = infer e in Γ', get S and τ' = apply S to parameter type: Sτ = return (S, Sτ → τ')

example: infer (λx.x)
  step 1: fresh → τ = t0
  step 2: Γ' = {x : t0}
  step 3: infer x in Γ' → S = ∅, τ' = t0
  step 4: Sτ = t0
  step 5: return (∅, t0 → t0)

example: infer (λx.λy.x) assuming some context leads to constraints
  step 1: fresh → τ = t0
  step 2: Γ' = {x : t0}
  step 3: infer (λy.x) in Γ' → S = ∅, τ' = t1 → t0
  step 4: Sτ = t0 (no substitution affects t0 here)
  step 5: return (∅, t0 → (t1 → t0))

let-polymorphism rule

  Γ ⊢ e1 : τ, S1   S1Γ, x : generalize(S1Γ, τ) ⊢ e2 : τ', S2
  -----------------------------------------------------------   [LET]
  Γ ⊢ let x = e1 in e2 : τ', S2S1

the algo: = infer e1 under Γ, get S1 and τ = apply S1 to context: S1Γ = generalize τ under S1Γ to get σ = generalize(S1Γ, τ) = extend context: Γ' = S1Γ, x : σ = infer e2 under Γ', get S2 and τ' = return (S2S1, τ')

example: infer (let id = λx.x in (id 5, id "hi"))
  step 1: infer (λx.x) → S1 = ∅, τ = t0 → t0
  step 2: S1Γ = Γ (identity, since S1 is empty)
  step 3: generalize(Γ, t0 → t0) → σ = ∀t0.t0 → t0
  step 4: Γ' = {id : ∀t0.t0 → t0}
  step 5: infer (id 5, id "hi") under Γ'
          first use of id:  instantiate → t1 → t1, unify with Int → ...
          second use of id: instantiate → t2 → t2, unify with String → ...
          → S2 = [...], τ' = (Int, String)
  step 6: return (S2S1 = S2, (Int, String))

inferW :: Context -> Expr -> InferM (Subst, Mono)
inferW ctx expr = case expr of
  Var x -> case Map.lookup x ctx of
    Nothing   -> throwError $ "unbound variable: " ++ x
    Just sigma -> do
      m <- instantiate sigma
      return (mempty, m)

  App e1 e2 -> do
    (s1, t1) <- inferW ctx e1
    (s2, t2) <- inferW (apply s1 ctx) e2
    tv <- fresh
    let t = TyVar tv
    s3 <- liftUnify (apply s2 t1) (TyFun t2 t)
    return (compose s3 (compose s2 s1), apply s3 t)

  Abs x e -> do
    tv <- fresh
    let ctx' = Map.insert x (Mono $ TyVar tv) ctx
    (s1, t1) <- inferW ctx' e
    return (s1, TyFun (apply s1 (TyVar tv)) t1)

  Let x e1 e2 -> do
    (s1, t1) <- inferW ctx e1
    let sigma = generalize (apply s1 ctx) t1
        ctx' = Map.insert x sigma (apply s1 ctx)
    (s2, t2) <- inferW ctx' e2
    return (compose s2 s1, t2)

tests & examples

claude-4.5-sonnet-thinking provides several test cases to demonstrate algorithm w in action.


-- helper to run inference from empty context
runInfer :: Expr -> Either String Mono
runInfer e = case runState (runExceptT (inferW Map.empty e)) 0 of
   (Left err, _)     -> Left err
   (Right (_, t), _) -> Right t

-- helper to run inference with a context
runInferWith :: Context -> Expr -> Either String Mono
runInferWith ctx e = case runState (runExceptT (inferW ctx e)) 0 of
   (Left err, _)     -> Left err
   (Right (_, t), _) -> Right t

-- test cases
main :: IO ()
main = do
   putStrLn "=== hindley-milner type inference tests ===\n"

   -- test 1: identity function
   putStrLn "test 1: identity function (λx.x)"
   let idFunc = Abs "x" (Var "x")
   print $ runInfer idFunc
   putStrLn "expected: t0 -> t0\n"

   -- test 2: const function (λx.λy.x)
   putStrLn "test 2: const function (λx.λy.x)"
   let constFunc = Abs "x" (Abs "y" (Var "x"))
   print $ runInfer constFunc
   putStrLn "expected: t0 -> t1 -> t0\n"

   -- test 3: self-application (λx.x x) - should fail
   putStrLn "test 3: self-application (λx.x x) - should fail with occurs check"
   let selfApp = Abs "x" (App (Var "x") (Var "x"))
   print $ runInfer selfApp
   putStrLn "expected: Left \"Occurs check fails: ...\"\n"

   -- test 4: simple application
   putStrLn "test 4: application ((λx.x) (λy.y))"
   let simpleApp = App idFunc idFunc
   print $ runInfer simpleApp
   putStrLn "expected: t1 -> t1\n"

   -- test 5: let-polymorphism - the key test
   putStrLn "test 5: let-polymorphism (let id = λx.x in id)"
   let letPoly = Let "id" idFunc (Var "id")
   print $ runInfer letPoly
   putStrLn "expected: t1 -> t1\n"

   -- test 6: polymorphic let with multiple uses at different types
   -- demonstrates each use of polymorphic id gets fresh type variables
   putStrLn "test 6: let with application (let id = λx.x in id id)"
   let letApp = Let "id" idFunc (App (Var "id") (Var "id"))
   print $ runInfer letApp
   putStrLn "expected: t2 -> t2\n"

   -- test 7: nested let
   putStrLn "test 7: nested let (let x = λa.a in let y = x in y)"
   let nestedLet = Let "x" idFunc (Let "y" (Var "x") (Var "y"))
   print $ runInfer nestedLet
   putStrLn "expected: t2 -> t2\n"

   -- test 8: function composition structure (λf.λg.λx.f (g x))
   putStrLn "test 8: composition structure (λf.λg.λx.f (g x))"
   let compose = Abs "f" (Abs "g" (Abs "x"
                  (App (Var "f") (App (Var "g") (Var "x")))))
   print $ runInfer compose
   putStrLn "expected: (t3 -> t4) -> (t2 -> t3) -> t2 -> t4\n"

   -- test 9: let with shadowing
   putStrLn "test 9: let with shadowing (let x = λa.a in let x = λb.b in x)"
   let shadowLet = Let "x" idFunc (Let "x" (Abs "b" (Var "b")) (Var "x"))
   print $ runInfer shadowLet
   putStrLn "expected: t2 -> t2\n"

   -- test 10: demonstrating generalization matters
   putStrLn "test 10: let id = λx.x in (let y = id in y)"
   let genTest = Let "id" idFunc (Let "y" (Var "id") (Var "y"))
   print $ runInfer genTest
   putStrLn "expected: t2 -> t2\n"

   putStrLn "\n=== all tests completed ==="

appendices

notation & symbols

||| type-level symbols

  τ, τ', τ1, τ2          monotypes (concrete type expressions)
  σ, σ'                  polytypes (type schemes with ∀ quantifiers)
  α, β, γ                type variables (placeholders for unknown types)
  t0, t1, t2, ...        fresh type variables generated during inference

||| context & environment

  Γ                      typing context (maps variables to polytypes)
  Γ, x : σ               context extended with binding x : σ
  S1Γ                    context with substitution S1 applied to all types

||| substitutions

  S, S1, S2, S3          substitutions (maps type variables to monotypes)
  ∅                      empty substitution (identity)
  [α ↦ τ]                substitution mapping α to τ
  S2S1                   composition: apply S1 first, then S2
  Sτ                     apply substitution S to type τ

||| typing judgments

  Γ ⊢ e : τ, S           under context Γ, expression e has type τ with substitution S
  x : σ ∈ Γ              variable x has type σ in context Γ
  τ ~ τ'                 types τ and τ' can be unified

||| operations

  generalize(Γ, τ)       turn monotype τ into polytype by quantifying free vars not in Γ
  instantiate(σ)         turn polytype σ into monotype by replacing ∀-bound vars with fresh vars
  mgu(τ1, τ2)            most general unifier: substitution making τ1 and τ2 equal
  fresh                  generate a new unique type variable

||| logical symbols

  →                     function type constructor (τ1 → τ2)
  ∀                     universal quantifier (∀α.τ)
  ∈                     element of / membership
  ⊢                     entailment / typing judgment
  ---                   inference rule separator (premises above, conclusion below)
1 full source code available at github.
2 there should only exist the Forall constructor. we explicitly add a Mono branch here to simplify pattern matching.
3 mgu is analogous to the initial object in the context of category theory.