Suppose we input n :: Int
at runtime from STDIN. We then read n
strings, and store them into vn :: Vect n String
(pretend for the moment this can be done).
Similarly, we can read m :: Int
and vm :: Vect m String
. Finally, we concatenate the two vectors: vn ++ vm
(simplifying a bit here). This can be type checked, and will have type Vect (n+m) String
.
Now it is true that the type checker runs at compile time, before the values n,m
are known, and also before vn,vm
are known. But this does not matter: we can still reason symbolically on the unknowns n,m
and argue that vn ++ vm
has that type, involving n+m
, even if we do not yet know what n+m
actually is.
It is not that different from doing math, where we manipulate symbolic expressions involving unknown variables according to some rules, even if we do not know the values of the variables. We don't need to know what number is n
to see that n+n = 2*n
.
Similarly, the type checker can type check
-- pseudocode
readNStrings :: (n :: Int) -> IO (Vect n String)
readNStrings O = return Vect.empty
readNStrings (S p) = do
s <- getLine
vp <- readNStrings p
return (Vect.cons s vp)
(Well, actually some more help from the programmer could be needed to typecheck this, since it involves dependent matching and recursion. But I'll neglect this.)
Importantly, the type checker can check that without knowing what n
is.
Note that the same issue actually already arises with polymorphic functions.
fst :: forall a b. (a, b) -> a
fst (x, y) = x
test1 = fst @ Int @ Float (2, 3.5)
test2 = fst @ String @ Bool ("hi!", True)
...
One might wonder "how can the typechecker check fst
without knowing what types a
and b
will be at runtime?". Again, by reasoning symbolically.
With type arguments this is arguably more obvious since we usually run the programs after type erasure, unlike value parameters like our n :: Int
above, which can not be erased. Still, there is some similarity between universally quantifying over types or over Int
.