loading...

Functional design: TDD in TypeScript (aka abusing `declare`)

gcanti profile image Giulio Canti Updated on ・3 min read

Functional design (6 Part Series)

1) Functional design: combinators 2) Functional design: how to make the `time` combinator more general 3 ... 4 3) Functional design: tagless final 4) Functional design: smart constructors 5) Functional design: TDD in TypeScript (aka abusing `declare`) 6) Functional design: Algebraic Data Types

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 declared 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.

Functional design (6 Part Series)

1) Functional design: combinators 2) Functional design: how to make the `time` combinator more general 3 ... 4 3) Functional design: tagless final 4) Functional design: smart constructors 5) Functional design: TDD in TypeScript (aka abusing `declare`) 6) Functional design: Algebraic Data Types

Posted on Mar 18 '19 by:

Discussion

markdown guide
 

Wow, awesome article!
Your explanation on how to go from the very abstraction of a type declaration to the actual implementation is top 🥇!
Thanks for writing and sharing this article. I guess I could learn a lot from you!

Regards,
Jonas