4 Comments

Type-Driven Development – Replacing Unit Tests with Types in Typescript

Being explicit about the return type of the function is the most under-utilized feature of TypeScript. At least, that’s true for the way I write my code.

When I first started using TypeScript, I was really impressed with the way it inferred the return type of the function, and having these types perpetuate throughout the app without any effort on my part made it really easy to switch over from JavaScript. But as I use TypeScript more and more, I’ve slowly realized the benefits of being explicit about types, especially the return types of functions.

Recently, I’ve noticed how explicit return types have completely replaced unit tests for a particular class of functions. A function that is responsible for transforming some data model into another data model can be entirely be driven by the type system, instead of tests.

Lately, I’ve been trying to identify functions that can utilize “type-driven development.” By replacing traditional test-driven development with type-driven development, I’ve been able to create robust software that’s much easier to maintain.

An Example

I recently came across this function that utilized type-driven development. It took in a list of configuration objects and produced a list of names for the configuration options embedded within the objects.


interface ConfigurationCategory {
  categoryName: string;
  options: Array<{ fieldName: string; value: boolean; }>;
}

function getFieldNamesForConfigurationCategories(categories: Array) {
  // Unit test needed before implementation...
}

Typically, I’d want to start by writing a few tests to make sure I can handle the data munging correctly. However, by simply adding a return type to the function, many of the details that could be driven by a unit test are now handled by the type system.

Here is the way I ended up implementing this particular function:


interface ConfigurationCategory {
  categoryName: string;
  options: Array<{ fieldName: string; value: boolean; }>;
}

function getFieldNamesForConfigurationCategories(categories: Array): string[] {
  return _.compact(_.flatten(categories.map((category) => {
    if (category === undefined) {	
      return undefined;
    }
    return category.options.map((option) => {
      return `${category.catgeoryName} - ${option.fieldName}`;
    });
  })));
}

I’ve noticed that functions like these use nested maps and a few Lodash utilities. The behavior of utility functions is typically morphing the data structure, which can easily be verified with types. For example, if you removed `_.compact`, the type system would complain that the actual return type is `Array`. Or without the `_.flatten`, the return type would be `string[][]`.

Additional Thoughts

The type system can’t replace all unit tests. For instance, in the example above, we can’t verify the resulting string with the types.

I’ve found that these holes in the type verification system force me to think of alternative implementations. In this case, maybe I could inject the stringifying behavior so it could be tested independently, like this:


interface ConfigurationOption {
  fieldName: string;
  value: boolean;
}

interface ConfigurationCategory {
  categoryName: string;
  options: ConfigurationOption[];
}

function getFieldNamesForConfigurationCategories(
  categories: Array, 
  constructFieldName: (category: ConfigurationCategory) => (option: ConfigurationOption) => string,
): string[] {
  return _.compact(_.flatten(categories.map((category) => {
    if (category === undefined) {	
      return undefined;
    }
    return category.options.map(constructFieldName(category));
  })));
}

At the very least, I could always write a traditional unit test for this function to check the outputted string. With the return type in place, I could assume the data model is correct, and I wouldn’t have to write cases that could potentially produce null results. Also, I need to have system tests in place to make sure that this function behaves correctly when connected to the rest of the software.

Since I’ve started using explicit return types, my thought process for testing code has changed. I now think about the different classes of behavior I can test with the types. And because these different classes of behaviors don’t need unit tests, I’ve been able to create better abstractions of these behaviors and encapsulate them in their own functions to use more generally.

For all of these reasons, I’d encourage anyone designing functions with TypeScript to use these return types.