---
title: Type-Driven Design, Test-Driven Design
teaser: 'TDD purism belongs to the world of dynamic typing.

  '
tags: types,testing,tdd
author: Mike Burns
published_on: 2021-02-08
---

[Type-driven design][] shines when you are working with static analysis, or when
the language you're working with provides good syntax for sketching out the
data concepts. The type checker can drive the implementation forward, and
ultimately you're done with the implementation when the code compiles.

[Type-driven design]: https://blog.ploeh.dk/2015/08/10/type-driven-development/

Conversely, test-driven design shines when the _implementation_ is easy, or
when the language you're working with provides ergonomics for _sketching out
implementation_. The tests drive the _data_ forward, and ultimately _you're
done with the data when the tests pass_.

## Thinking in types

A typical example: we need to place an order, which means we need to represent
an Order, a Fulfillment, and a Result. This order is placed on behalf of a
customer, so we also need a User.

An Order is actually a set of line items, so we need a Set and also LineItem.

```rust
struct Order {
  line_items: HashSet<LineItem>,
}

struct Fulfillment {
  tracking_id: String,
  order: Order,
}

#[derive(Debug)]
struct FulfillmentError {
  message: String,
}

impl Display for FulfillmentError {
  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    write!(f, &self.message)
  }
}

impl Error for FulfillmentError {}

struct User {
  orders: HashSet<Order>,
}

struct LineItem {
  name: String,
  sku: String,
}
```

Now that we have our types, we can place an order. The type checker will let us
know when we're done.

```rust
impl User {
  fn place_order(&self, order: &Order) -> io::Result<FulfillmentError> {
    unimplemented!()
  }
}
```

## Thinking in tests

We can focus our vocabulary to fit within the tools that we use in
implementation. We can give a more test-driven phrasing of the example.

We need to place an order, which means we need to pass an Order object to a
method on Fulfillment, and handle the result of that. This order is placed on
behalf of a customer, so the User object should be in charge.

The Fulfillment works in terms of line items, so we'll need to think at that
level.

```ruby
describe "#place_order" do
  it "works with a successful fulfillment API" do
    api = stub_fulfillment_api
    user = create(:user)
    line_item = create(:line_item)
    order = create(:order, user: user, line_items: [line_item])

    result = user.place_order(order)

    expect(user).to be_valid
    expect(api).to have_placed_an_order_for(line_item.sku)
  end

  it "works with a failed fulfillment API" do
    api = stub_fulfillment_api(success: false)
    user = create(:user)
    line_item = create(:line_item)
    order = create(:order, user: user, line_items: [line_item])

    result = user.place_order(order)

    expect(user).to be_invalid
    expect(api).not_to have_placed_an_order_for(line_item.sku)
  end
end
```

Now that we have that, we can define our classes. The test suite will let us
know when we're done.

```ruby
class Order < Struct.new(:line_items)
end

class LineItem < Struct.new(:name, :sku)
end

class Fulfillment
  def place_order(line_items)
    raise NotImplementedError,
      "TODO: order the line items and return an ActiveModel instance"
  end
end

class User
  include ActiveModel::Model
  attr_accessor :orders

  def place_order(order)
    api = Fulfillment.new
    response = api.place_order(order.line_items)

    unless response.valid?
      response.errors.each do |error|
        errors.import(error, attribute: :orders)
      end
    end
  end
end
```

We drove all of this out by using tests to focus on the design of the code.
This is different from using tests to ferret out bugs. These tests cover
roughly the same goals as the types in the type-driven design example. In both
situations you will want additional tests to think through the domain problems.

## An analysis

Neither of these techniques are wrong, but they are different.

People often talk, both jokingly and pejoratively, of "writing X in Y" --
writing FORTRAN in C, writing Java in Ruby, writing Rust in Go, etc. One of the
biggest mistakes you can make is to test-drive when the language calls for
type-driving, and vice versa. This is an effort in fighting your tooling.
Writing Python in Ruby means your code will look weird; writing Kotlin as if
it's JavaScript means you are not leveraging the tools that find bugs for you.

In this way, TDD purism belongs to the world of dynamic typing; being a TDD
purist in Rust is a mistake, but being a type-driven designer in Ruby is also a
mistake.

And that's that on nuance.
