Type driven development (TDD) is a technique used to split a problem into a set of smaller problems, letting the type checker suggest the concrete implementation, or at least helping us getting there. Here's a practical example.
Say for instance that we like to reimplement the function Promise.all
, we'll name it sequence
. Let's start with its signature
// TODO
declare function sequence<T>(promises: Array<Promise<T>>): Promise<Array<T>>
Notice that a declare
d function, even if not implemented yet, is immediately available and can concur to type check the rest of our code. If we are running a build system though, we'll get an error because there's no such function at runtime, it only exists in the "world of types" for now.
However, the goal of this technique is working in the editor for as long as possible without the need to run our code. We'll rely entirely on the type checker making sure it doesn't raise any errors as we go through.
Back to our problem, we need to transform (or "reduce") an array of values of type A
to a value of type B
.
The transformation we're looking for is likely reduce
, which in fact has the following signature
declare function reduce<A, B>(this: Array<A>, f: (acc: B, x: A) => B, init: B): B
We don't know how to implement neither f
nor init
yet, but we can skip the concrete implementation as we did before. For now it's enough to simply declare
the missing bits and replace the type parameters A
and B
with the corresponding types we're working with:
A = Promise<T>
B = Promise<Array<T>>
This is what we get
// TODO
declare function pushPromise<T>(acc: Promise<Array<T>>, x: Promise<T>): Promise<Array<T>>
// TODO
function sequence<T>(promises: Array<Promise<T>>): Promise<Array<T>> {
declare const init: Promise<Array<T>> // TypeScript error
return promises.reduce(pushPromise, init)
}
Alas declare
can't be used inside a function body so we need a temporary workaround
declare const TODO: any
// TODO
declare function pushPromise<T>(acc: Promise<Array<T>>, x: Promise<T>): Promise<Array<T>>
// partially implemented
function sequence<T>(promises: Array<Promise<T>>): Promise<Array<T>> {
const init: Promise<Array<T>> = TODO
return promises.reduce(pushPromise, init)
}
Now init
is straightforward, we can put an empty array into the Promise "container"
const init: Promise<Array<T>> = Promise.resolve([])
Implementing pushPromise
is more complicated, we know how to concat a value of type Array<T>
with a value of type T
to get another value of type Array<T>
, let's name it push
declare function push<T>(x: Array<T>, y: T): Array<T>
but how do we concat acc
and x
in pushPromise
given that they are both promises?
What we'd like to have is a procedure, let's name it liftA2
, which can "lift" the function push
producing a new function which can work on the values "inside" the promises. Again we just declare
the expected result without any implementation
// TODO
declare function liftA2<A, B, C>(
f: (a: A, b: B) => C
): (fa: Promise<A>, fb: Promise<B>) => Promise<C>
// TODO
declare function push<T>(x: Array<T>, y: T): Array<T>
// implemented
function pushPromise<T>(acc: Promise<Array<T>>, x: Promise<T>): Promise<Array<T>> {
return liftA2<Array<T>, T, Array<T>>(push)(acc, x)
}
// implemented
function sequence<T>(promises: Array<Promise<T>>): Promise<Array<T>> {
const init: Promise<Array<T>> = Promise.resolve([])
return promises.reduce(pushPromise, init)
}
Now we only need to implement liftA2
and push
function liftA2<A, B, C>(
f: (a: A, b: B) => C
): (fa: Promise<A>, fb: Promise<B>) => Promise<C> {
return (a, b) => a.then(aa => b.then(bb => f(aa, bb)))
}
function push<T>(x: Array<T>, y: T): Array<T> {
return x.concat([y])
}
function pushPromise<T>(acc: Promise<Array<T>>, x: Promise<T>): Promise<Array<T>> {
return liftA2<Array<T>, T, Array<T>>(push)(acc, x)
}
function sequence<T>(promises: Array<Promise<T>>): Promise<Array<T>> {
const init: Promise<Array<T>> = Promise.resolve([])
return promises.reduce(pushPromise, init)
}
Let's try it out
sequence([]).then(x => console.log(x)) // []
sequence([Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)]).then(x =>
console.log(x)
) // [1, 2, 3]
As you can see a strong type system can not only prevent errors, but also guide you and provide feedback in your design process.
Top comments (0)