Published: 17th October 2026
Reading introductions to programming with dependent types, one usually encounters a vector. It is a list that also carries information about its length on the type-level. This allows for more precise specification of program behaviour and helps ruling out even more errors during compile time.
Consider following declarations:
-- Forward declaration of 'Vec' type constructor and functions.
-- Data constructors and implementations may be completed later.
-- 'Vec' is a type constructor accepting 2 arguments. First is a natural number
-- and the other is a type.
data Vec : Nat -> Type -> Type
-- Put an item at the end of a vector. (S n) means "Successor of n", so length
-- of the output vector will be 1 more than the input vector.
append : a -> Vec n a -> Vec (S n) a
-- Concatenate one vector with another.
concat : Vec m a -> Vec n a -> Vec (m + n) a
-- Return the first element of a vector. It only accepts a vector of length
-- at least 1, which makes the function infallible.
head : Vec (S n) a -> a
-- Transform each element of a vector by a function.
map : (a -> b) -> Vec n a -> Vec n b
-- Reverse the vector.
reverse : Vec n a -> Vec n a
-- Given vectors of equal length, return a vector of pairs from the original
-- vectors.
zip : Vec n a -> Vec n b -> Vec n (a, b)Even without implementations, we can deduce a plenty of information from the signatures alone, like:
map, reverse and zip preserve
the length of the vector.concat results in a vector whose length is the sum of
lengths of the input vectors.zip requires both vectors to be of the same
length.List.combine
or Python zip(..., strict=True)
when lengths are unequal.zip(..., strict=False) is forbidden.head requires the vector to be of length at least
1.Maybe a type to take the empty vector
into account - it is already handled at the type-level.However, one well-known function is usually absent in the introductions:
-- Create a vector of elements that satisfy a predicate from the input vector.
filter : (a -> Bool) -> Vec n a -> ???In this article, I implement everything from scratch in a lovely
programming language Idris 2. By
the end, we will understand both how filter works and why
it is usually omitted from introductory examples.
Disclaimer: It’s a shame I need to even mention it,
but the year is 2026 and folks are trying to cut corners with their
(ab)use of generative AI. This article is written by me and me
only.
I prompted myself to “Make no mistakes.”. But I am just a guy on the Internet, so double-check everything.
Before we begin though, a quick note on natural numbers, because they are central to dependently-typed programming languages. Their representation in the type system is similar to:
data Natural : Type where
-- Base case, zero, 0, ...
Zero : Natural
-- Step, successor, 1 + _, ...
Successor : Natural -> NaturalNumber 3 then looks like:
threeNatural : Natural
threeNatural = (Successor (Successor (Successor Zero)))
-- The canonical version from Prelude. Reserving one-letter constructors
-- for them saves a bit of typing, since they are used often.
threeNat : Nat
threeNat = (S (S (S Z)))During runtime, they are normal machine integers, since arithmetic in
this (unary) representation, albeit doable, would be hopelessly
inefficient.
Having them defined as a data type with Z
(for 0 value) and S (for successor function) constructors
helps reasoning though. Techniques like mathematical (or more generally
structural) induction rely on this representation.
Vec definition and
examplesWith clarification of the Z and S symbols
out of the way, let’s implement the vector data type:
data Vec : Nat -> Type -> Type where
-- An empty vector.
Empty : Vec Z a
-- 'Cons' is a historic name for prepending an item to a list, coming from
-- Lisp.
-- This constructor accepts 2 arguments, an item and a vector and returns a
-- vector of length 1 more than the input vector.
Cons : a -> Vec n a -> Vec (S n) aVec itself is a type constructor with 2
arguments.
Argument n is a natural number, not a type.
It is usually called an “index”, because it varies across data
constructors. You can see it is equal to Z for
Empty and (S n) for Cons.
Argument a is usually called a “parameter”, because it is
uniform across all data constructors. Parameters can be thought of as
“generic types” in mainstream languages.
Empty and Cons are the data
constructors of Vec. Each data constructor can
have different arguments. Calling them constructs values of their
respective types - Empty constructs a value of type
Vec 0 a and Cons of type
Vec (S n) a.
Here are some examples:
v1 : Vec 0 _
v1 = Empty
v2 : Vec 1 Char
v2 = Cons 'x' Empty
v3 : Vec 2 Nat
v3 = Cons 1 (Cons 2 Empty)
failing "Mismatch between: 0 and 1."
vBad : Vec 1 Nat
vBad = EmptyBesides few successful instantiations, you can observe a feature of
Idris called failing
blocks.
It allows a syntactically valid code that does not compile
be part of your source code, which I find rather nifty.
The
(optional) string after the failing keyword is a part of an
error message compiler would have emitted had the code block not been
inside failing block, so:
-- failing "Mismatch between: 0 and 1."
vBad : Vec 1 Nat
vBad = Emptyproduces:
$ idris2 Vec.idr
...
1/1: Building Vec (Vec.idr)
Error: While processing right hand side of vBad. When unifying:
Vec 0 Nat
and:
Vec 1 Nat
Mismatch between: 0 and 1.
Vec:59:8--59:13
55 | v3 = Cons 1 (Cons 2 Empty)
56 |
57 | -- failing "Mismatch between: 0 and 1."
58 | vBad : Vec 1 Nat
59 | vBad = Empty
^^^^^
The error tells us that during unification (solving a system of type
equations), vBad was declared to be of type
Vec 1 Nat, but Empty can only be
Vec 0 Nat. It is impossible to satisfy both, so a mismatch
between 0 and 1 is reported.
map (that works)Armed with the definition of Vec, let’s implement our
first version of map, called mapNaive:
mapNaive : (a -> b) -> Vec n a -> Vec n b
mapNaive _ Empty = Empty
mapNaive f (Cons x xs) = Cons (f x) (mapNaive f xs)We can perform pattern matching on the data constructors directly in the function definition.
Empty case:
This is the base case, so no matter the function, Empty
must be returned.
Cons case:
Cons means the length of the input vector is at least 1,
so S k. Therefore, length of xs is any natural
number k. Because the recursive step
mapNaive f xsis immediately used in the Cons constructor, which
increases the length by 1, Idris is able to deduce the
overall length of the vector stays the same.
Idris has a nice REPL support, so we can load the file
and try few examples.
Note that (\x => x + 123) is
an anonymous function syntax.
$ idris2 Vec.idr
...
Vec> mapNaive S (Cons 1 (Cons 2 Empty))
Cons 2 (Cons 3 Empty)
Vec> mapNaive (\x => x + 123) (Cons 1 (Cons 2 Empty))
Cons 124 (Cons 125 Empty)
There is nothing wrong with the implementation per se, the reason I
call it “naive” is because it is not tail-recursive. It allocates stack
space in proportion to the length of the vector, so calling this
function on a vector of length say 1 million will probably get the
program terminated. In later installments, we will come back to
map and make it tail-recursive.
filter (that does not work)Following our success with map, let’s try the same
approach with filter. We introduce another index
m for the resulting vector length, because we do not know
a priori how long it is going to be:
filterBad : (a -> Bool) -> (Vec n a) -> (Vec m a)That will not type check though.
Empty case:
failing "Mismatch between: 0 and m."
filterBadEmpty : (a -> Bool) -> (Vec n a) -> (Vec m a)
filterBadEmpty _ Empty = EmptyThe error tells us that during unification, m is
required to simultaneously be equal to 0 and be unconstrained. This
cannot be satisfied and unification fails.
Cons case:
failing "Mismatch between: S ?m and m."
filterBadCons : (a -> Bool) -> (Vec n a) -> (Vec m a)
filterBadCons pred (Cons x xs) =
let rest := filterBadCons pred xs in -- `rest` is of type `Vec m a`
if pred x then
Cons x rest -- Wrong, since value of type `Vec (S m) a` would be returned!
else
restVector rest, which is a result of recursive call to
filterBadCons, is declared to be of type
Vec m a. So, if x satisfies the predicate
pred, prepending it to rest changes the result
type to Vec (S m) a.
No natural number satisfies
k = k + 1, therefore unification fails.
It seems we are in a pickle. We know there exists m that
would be equal to length of the filtered vector. But with our current
approach, we have no way to encode this knowledge of existence of
m into the type system. The actual value depends on the
predicate, which is an information only known at runtime, because
different predicates may yield different lengths. And since length is
part of the type now, we cannot construct a vector until we know its
length!
So … what now?
Looking back at the mapNaive function, it does not
really care about the value of an index n in
Vec n a. It can be anything - 0, 1, 1337, …
mapNaive transforms elements of the vector, length remains
fixed.
filter, on the other hand, changes the length of the
vector in a way that is impossible to determine at compile time. As
mentioned in the previous section, it depends on the predicate. We
somehow need to bundle1 a number together with a vector that
is guaranteed to have length equal to this number into a single
type.
Something like:
import dataclasses
import typing as t
@dataclasses.dataclass(frozen=True, slots=True)
class LengthAwareList[A]:
n: int
lst: list[A]
def __post_init__(self) -> None:
# `__post_init__` is a special method of data classes that is executed
# post initialization.
assert self.n == len(self.lst)but with a compile time guarantee rather than a runtime check. :)
Solution to our struggles is a construction known as a dependent sum type.
It’s a record (or a tuple) with a twist. Instead of types of its fields being fixed, the distinguishing feature of dependent pair types is that field types defined later in the body can depend on the values of fields already defined.
-- Record is a data type with a single constructor. Fields are named and
-- projections (field accessors) are automatically generated.
record LengthAwareVec (a : Type) where
constructor MkLengthAwareVec
len : Nat
-- Type of 'vec' field depends on the **value** of 'len' field!
vec : Vec len aObserve that the len field is referenced in a type
annotation of the vec field.
Playing with it a bit:
lav1 : LengthAwareVec Char
lav1 = MkLengthAwareVec 0 Empty
lav2 : LengthAwareVec Char
lav2 = MkLengthAwareVec 1 (Cons 'a' Empty)
failing "Mismatch between: 1 and 0."
lavBad : LengthAwareVec Char
lavBad = MkLengthAwareVec 0 (Cons 'a' Empty)Let’s see if we make any progress with this fancy new feature.
filter (that finally works)First, function signature needs to reflect the new type. Again, “naive” is to be understood as not tail-recursive.
filterNaive : (a -> Bool) -> (Vec n a) -> LengthAwareVec aEmpty case:
Filtering an empty vector is the base case and no matter the
predicate, Empty vector is returned. Here, it needs to be
bundled with Z for length 0.
filterNaive _ Empty = MkLengthCheckedVec Z EmptyCompiler accepts this implementation and also correctly rejects a counting mistake:
failing "Mismatch between: 0 and 1."
filterNaiveBadEmptyLength : (a -> Bool) -> (Vec n a) -> LengthAwareVec a
filterNaiveBadEmptyLength _ Empty = MkLengthAwareVec 1 EmptyCons case:
filterNaive pred (Cons x xs) =
let rest := filterNaive pred xs in
if pred x then
MkLengthAwareVec (S rest.len) (Cons x rest.vec)
else
restAnd again, implementation is accepted!
In the True branch, you can see that incrementing the
length and prepending an item to a vector must be done in a lockstep.
Omitting one or the other results in an error:
failing "Can't solve constraint between: S (len rest) and rest .len"
filterNaiveNoS : (a -> Bool) -> (Vec n a) -> LengthAwareVec a
filterNaiveNoS pred (Cons x xs) =
let rest := filterNaive pred xs in
if pred x then
MkLengthAwareVec rest.len (Cons x rest.vec) -- Forgotten (S rest.len)
else
rest
failing "Can't solve constraint between: rest .len and S (rest .len)"
filterNaiveNoCons : (a -> Bool) -> (Vec n a) -> LengthAwareVec a
filterNaiveNoCons pred (Cons x xs) =
let rest := filterNaive pred xs in
if pred x then
MkLengthAwareVec (S rest.len) rest.vec -- Forgotten (Cons x rest.vec)
else
restHere are some examples from the REPL:
$ idris2 Vec.idr
...
Vec> filterNaive (\x => x > 2) Empty
MkLengthAwareVec 0 Empty
Vec> filterNaive (\x => x < 2) (Cons 1 (Cons 2 Empty))
MkLengthAwareVec 1 (Cons 1 Empty)
Vec> filterNaive (\x => x <= 2) (Cons 1 (Cons 2 Empty))
MkLengthAwareVec 2 (Cons 1 (Cons 2 Empty))
Vec> filterNaive (\x => x > 2) (Cons 1 (Cons 2 Empty))
MkLengthAwareVec 0 Empty
That’s it! We are done!
Please note that Idris’ standard library already contains
Vect data type and common functions acting on it, including
filter. However, instead of returning a record, stdlib’s
filter returns a dependent tuple. Here is the signature,
using the :module (load a module) and :t (show
the type of expression) affordances of Idris REPL:
Vec> :module Data.Vect
Imported module Data.Vect
Vec> :t Data.Vect.filter
Data.Vect.filter : (elem -> Bool) -> Vect len elem -> (p : Nat ** Vect p elem)The
(p : Nat ** Vect p elem)is the dependent tuple syntax. Expressive power of records and tuples
is the same, I opted for the record because I find it a bit more
readable. Complete program below includes filterNaivePair
with dependent tuple for comparison.
To answer the question posed in the introduction of the article, the
signature of filter function is:
filter : (a -> Bool) -> Vec n a -> (m : Nat ** Vec m a)And I also believe all the talk about dependent sums justifies why
tutorials omit the filter function, so let me know if I did
a good job explaining it. :D
I sincerely hope I did not overwhelm you and you learnt something new today. Dependent types are a fascinating topic bridging type theory, formal methods, programming and others, so they are daunting.
I intend this article to be the first in series of posts exploring dependent types themselves as well as their connections to other topics I find interesting.
Namely:
My implementations of map and filter are
not tail-recursive. To correct it, we will need to dip our toes in
proving some theorems.
I used phrases like “there exists a length” and “map
works for all lengths”. Indeed, dependent sums can model existential
quantification, while their duals - dependent products - can model the
universal quantification.
I will visit this as well.
Dependent types let us really constrain the values. Properties like:
can be expressed and automatically checked by the compiler. How to leverage them to write safer programs? How to find a balance between type-level “golfing” and runtime checks?
The “Idris book” contains nice examples of using dependent types for modelling state machines. How does that compare to model checking done by eg. TLA+? Encoding a transaction or a network protocol in the type system would be an interesting exercise.
And many more … stay tuned for next installments!
To have the complete program at hand, it is provided here in its
entirety. You can download it to Vec.idr and launch a REPL,
as is done several times in the article.
Program:
module Vec
%default total
-- Forward declaration of 'Vec' type constructor and functions.
-- Data constructors and implementations may be completed later.
-- 'Vec' is a type constructor accepting 2 arguments. First is a natural number
-- and the other is a type.
data Vec : Nat -> Type -> Type
-- Put an item at the end of a vector. (S n) means "Successor of n", so length
-- of the output vector will be 1 more than the input vector.
append : a -> Vec n a -> Vec (S n) a
-- Concatenate one vector with another.
concat : Vec m a -> Vec n a -> Vec (m + n) a
-- Return the first element of a vector. It only accepts a vector of length
-- at least 1, which makes the function infallible.
head : Vec (S n) a -> a
-- Transform each element of a vector by a function.
map : (a -> b) -> Vec n a -> Vec n b
-- Reverse the vector.
reverse : Vec n a -> Vec n a
-- Given vectors of equal length, return a vector of pairs from the original
-- vectors.
zip : Vec n a -> Vec n b -> Vec n (a, b)
data Natural : Type where
-- Base case, zero, 0, ...
Zero : Natural
-- Step, successor, 1 + _, ...
Successor : Natural -> Natural
threeNatural : Natural
threeNatural = (Successor (Successor (Successor Zero)))
-- The canonical version from Prelude. Reserving one-letter constructors
-- for them saves a bit of typing, since they are used often.
threeNat : Nat
threeNat = (S (S (S Z)))
-- 'Vec' accepts 2 arguments. First is a natural number and the other is a type.
-- 'Vec 123 Char' is a 'Type'
-- 'Vec 123' is a type-level function, so 'Type -> Type'
data Vec : Nat -> Type -> Type where
-- An empty vector.
Empty : Vec Z a
-- 'Cons' is a historic name for prepending an item to a list, coming from
-- Lisp.
-- This constructor accepts 2 arguments, an item and a vector and returns a
-- vector of length 1 more than the input vector.
Cons : a -> Vec n a -> Vec (S n) a
v1 : Vec 0 _
v1 = Empty
v2 : Vec 1 Char
v2 = Cons 'x' Empty
v3 : Vec 2 Nat
v3 = Cons 1 (Cons 2 Empty)
failing "Mismatch between: 0 and 1."
vBad : Vec 1 Nat
vBad = Empty
mapNaive : (a -> b) -> Vec n a -> Vec n b
mapNaive _ Empty = Empty
mapNaive f (Cons x xs) = Cons (f x) (mapNaive f xs)
-- 'mapNaive' specialized for the 'Vec 0 a' case. Observe the function needs
-- to handle only the 'Empty' case, because it is impossible to construct a
-- value of a type 'Vec 0 a' with the 'Cons' constructor!
mapNaiveEmpty : (a -> b) -> Vec 0 a -> Vec 0 b
mapNaiveEmpty _ Empty = Empty
-- 'mapNaive' specialized for the 'Vec (S n) a' case. Again, function handles
-- only the 'Cons' case, because it is impossible to construct a value of type
-- 'Vec (S n) a' with the 'Empty' constructor.
-- However, implementation is a bit uglier. Here, the 'xs' tail of the vector
-- may be 'Empty', which means recursion hit the base case.
mapNaiveCons : (a -> b) -> Vec (S n) a -> Vec (S n) b
mapNaiveCons f (Cons x xs) =
let y := f x in
case xs of
Empty => Cons y Empty
Cons _ _ => Cons y (mapNaiveCons f xs)
failing "Mismatch between: 0 and m."
filterBadEmpty : (a -> Bool) -> (Vec n a) -> (Vec m a)
filterBadEmpty _ Empty = Empty
failing "Mismatch between: S ?m and m."
filterBadCons : (a -> Bool) -> (Vec n a) -> (Vec m a)
filterBadCons pred (Cons x xs) =
let rest := filterBadCons pred xs in -- `rest` is of type `Vec m a`
if pred x then
Cons x rest -- Wrong, since value of type `Vec (S m) a` would be returned!
else
rest
-- Record is a data type with a single constructor. Fields are named and
-- projections (field accessors) are automatically generated.
record LengthAwareVec (a : Type) where
constructor MkLengthAwareVec
len : Nat
-- Type of 'vec' field depends on the **value** of 'len' field!
vec : Vec len a
lav1 : LengthAwareVec Char
lav1 = MkLengthAwareVec 0 Empty
lav2 : LengthAwareVec Char
lav2 = MkLengthAwareVec 1 (Cons 'a' Empty)
failing "Mismatch between: 1 and 0."
lavBad : LengthAwareVec Char
lavBad = MkLengthAwareVec 0 (Cons 'a' Empty)
filterNaive : (a -> Bool) -> (Vec n a) -> LengthAwareVec a
filterNaive _ Empty = MkLengthAwareVec Z Empty
filterNaive pred (Cons x xs) =
let rest := filterNaive pred xs in
if pred x then
MkLengthAwareVec (S rest.len) (Cons x rest.vec)
else
rest
failing "Mismatch between: 0 and 1."
filterNaiveBadEmptyLength : (a -> Bool) -> (Vec n a) -> LengthAwareVec a
filterNaiveBadEmptyLength _ Empty = MkLengthAwareVec 1 Empty
failing "Can't solve constraint between: S (len rest) and rest .len"
filterNaiveNoS : (a -> Bool) -> (Vec n a) -> LengthAwareVec a
filterNaiveNoS pred (Cons x xs) =
let rest := filterNaive pred xs in
if pred x then
MkLengthAwareVec rest.len (Cons x rest.vec) -- Forgotten (S rest.len)
else
rest
failing "Can't solve constraint between: rest .len and S (rest .len)"
filterNaiveNoCons : (a -> Bool) -> (Vec n a) -> LengthAwareVec a
filterNaiveNoCons pred (Cons x xs) =
let rest := filterNaive pred xs in
if pred x then
MkLengthAwareVec (S rest.len) rest.vec -- Forgotten (Cons x rest.vec)
else
rest
------------
-- EXTRAS --
------------
-- To showcase how inefficient operations on unary representation of natural
-- numbers is. 'plusNatural' performs the following:
-- plusNatural 3 6 = plusNatural 2 7 = plusNatural 1 8 = plusNatural 0 9 = 9
plusNatural : Natural -> Natural -> Natural
plusNatural m n =
case m of
Zero => n
Successor k => plusNatural k (Successor n)
-- We can let Idris prove the equality of 'mapNaive' applying the 'Successor'
-- function 'S'.
-- mapNaive S [1, 2] = [2, 3]
-- More on theorem proving in future articles.
mapNaiveExample : (mapNaive S (Cons 1 (Cons 2 Empty))) = (Cons 2 (Cons 3 Empty))
mapNaiveExample = Refl
-- Instead of defining a custom record, Idris has special syntax for dependent
-- pairs.
-- (Nat, String) is an ordinary type of a pair, (m : Nat ** Vec m a) is the
-- dependent pair.
-- Expressive power of dependent records and pairs is the same, I opted for the
-- former in this article.
filterNaivePair : (a -> Bool) -> (Vec n a) -> (m : Nat ** Vec m a)
filterNaivePair _ Empty = (Z ** Empty)
filterNaivePair pred (Cons x xs) =
let (k ** rest) := filterNaivePair pred xs in
if pred x then
((S k) ** (Cons x rest))
else
(k ** rest)Word “bundle” was chosen on purpose. There is a concept of a “fiber bundle” in topology. Without going into too much detail, a dependent sum corresponds to an element of a bundle and a dependent product to a section of the bundle.↩︎