Type-Driven Design, Test-Driven Design

Mike Burns

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.

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.

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.

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.

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.

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.