The Power of Advanced TypeScript: Reducing Test Code with Better Types
As developers, we strive to write efficient, maintainable code. However, popular wisdom suggests that we need more test code than application code. But what if we could improve our code to actually avoid writing so many tests? In this article, we’ll explore how advanced TypeScript can help us achieve just that.
What is Advanced TypeScript?
Advanced TypeScript goes beyond simply using conditional types or specific language features. It’s about harnessing the power of the compiler to find logic flaws in our application code. By using types to restrict what our code can do, we can create more robust and maintainable software.
Type-Driven Development: The Key to Fewer Tests
Type-driven development is an approach that involves writing our TypeScript program around types. By choosing types that make it easy for the type checker to catch logic errors, we can model the states our application transitions in a way that’s both self-documenting and executable.
Tests are Good; Impossible States are Better
Richard Feldman’s talk “Making Impossible States Impossible” highlights the benefits of using a powerful type system like TypeScript. By expressing application logic in states that only reveal their fields and values depending on the current context, we can avoid writing unnecessary tests.
Case Study: Authentication Workflow
Let’s consider an authentication workflow as an example. We might start with a simple interface that seems uncontroversial but hides several hidden flaws. By introducing this interface into our code, we’d need to write tests to verify the many branching if statements.
Flaws with Using a Single-Interface Approach
One big problem with the single-interface approach is that nothing can stop us from assigning a valid string to the authToken field while isAuthenticated is false. What if it was possible for the authToken field only to be available to code when dealing with a known user?
Using Algebraic Data Types and Impossible States
By refactoring our AuthenticationStates type to use algebraic data types (ADTs), we can express the different states our application can be in. Each union member can contain fields that are only relevant to its specific kind. This approach allows us to narrow the type of state our application is in, making it impossible to access certain fields when they’re not available.
Determining if Union Members are Current
We can use type narrowing to determine which union member is current. By throwing an exception or using a type-safe validation schema, we can ensure that our code only accesses fields that are available in the current state.
Using ts-pattern Instead of Switch Statements
Switch statements are limited and can lead to errors. The ts-pattern library provides a better alternative, allowing us to express complex conditions in a single, compact expression.
Parse, Don’t Validate: Using a Type-Safe Validation Schema
Instead of writing validation functions that require many tests, we can create a type-safe schema that can execute against incoming data at runtime. The Zod package provides a simple way to define schemas that can parse data and extract TypeScript types.
Conclusion: Tests and Types are Not Mutually Exclusive
We’re not advocating for abandoning tests entirely. Instead, we can use the type check function to check our logic directly against the application code. By writing better types, we can reduce the amount of unit testing code we need to write, focusing instead on integration and end-to-end tests that test the whole system.