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.

Conversation
  • “return types have completely replaced unit tests for a particular class of functions”

    This is what I experienced too. The functions and classes without much business logic are sometimes being tested very naively. 100% code coverage in boilerplate. Types help a lot with these.

    Still there are some tests that need to be written with or without strict typing. But it is nice to reduce the boilerplate and be more formal with types

  • Sebastian Sobociński says:

    haha

  • Kev says:

    If you are checking for types with that function you aren’t really testing the functionality. A correct type does not mean you have a correct result. Typical TS FUD though. Checking the shape of data isn’t (in this case) actually useful. The actual value returned is much more important. There are numerous ways the implementation could change but the “type test” still pass, hence giving developers a false sense of security.

  • Jos says:

    Thanks for sharing your experience. You may be interested in the following article of Eric Elliot which also discussed this topic but comes to the opposite conclusion. Like Kev says, you’re only testing types, not behavior!

    The TypeScript Tax: a Cost vs Benefit Analysis
    https://medium.com/javascript-scene/the-typescript-tax-132ff4cb175b

  • Danilo Morães says:

    The boiler plate code testing you said you used to do was not necessary in the first place. You want to test that your function works with expected input only. You should only test for errors if treating errors is a functionality of your function. If your function should fail gracefully on unexpected input, then that’s a functionality and you have to test regardless of the type system you use. Type doesn’t decrease the amount of tests needed to guarantee functionality.

  • Comments are closed.