Write fewer tests by creating better TypeScript types
Tags: typescript, webdev
Published: 2022-11-09 20:06:03

**Written by [Paul Cowan](https://blog.logrocket.com/author/paulcowan/)✏️**

Every line of code you write carries the requirement of maintenance through the lifetime of an application. The more lines of code you have, the more maintenance is required, and the more code you have to change as the application adapts to the evolving requirements. 

Unfortunately, popular wisdom decrees that we need more test code than application code. In this post, I’m going to discuss why this idea is not quite correct, and how we can improve our code to actually avoid writing so many tests. 

First, we should address a few things:

### Advanced TypeScript is not just conditional types or specific language features

I've seen some online courses proclaim that advanced TypeScript is simply picking a few complex features from the laundry list of TypeScript features, such as conditional types. 

In truth, advanced TypeScript uses the compiler to find logic flaws in our application code. Advanced TypeScript is the practice of using types to restrict what your code can do and uses tried-and-trusted paradigms and practices from other type systems.

### Type-driven development is always the best approach

Type-driven development is writing your TypeScript program around types and choosing types that make it easy for the type checker to catch logic errors. With type-driven development, we model the states our application transitions in type definitions that are self-documenting and executable. 

Non-code documentation becomes out of date the moment it is written. If the documentation is executable, we have no choice but to keep it up to date. Both tests and types are examples of executable documentation.

### Tests are good; impossible states are better

I am shamelessly stealing the above heading from the excellent talk, [Making Impossible States Impossible](https://www.youtube.com/watch?v=IcgmSRJHu_8&t=948s) by Richard Feldman. 

For my money, the real benefit of using a powerful type system like TypeScript is to express application logic in states that only reveal their fields and values depending on the current context of the running application. 

In this post, I will use an authentication workflow as an example of how we can write better types to avoid writing so many tests. 

_Jump ahead:_

*   [Flaws with using a single-interface approach](#flaws-single-interface-approach)
*   [Using algebraic data types and impossible states](#algebraic-data=types-impossible-states)
*   [Determining if union members are current (a.k.a., type narrowing)](#determining-union-members-current)
*   [Using `ts-pattern` instead of switch statements](#ts-pattern-instead-switch-statements)
*   [Parse, don’t validate: Using a type-safe validation schema](#type-safe-validation-schema)

## Flaws with using a single-interface approach <a name="flaws-single-interface-approach">

When thinking about authentication, it boils down to whether or not the current user is known to the system. 

The following interface seems uncontroversial, but it hides several hidden flaws:

```typescript
export interface AuthenticationStates {
  readonly isAuthenticated: boolean;
  authToken?: string;
}
```

If we were to introduce this seemingly small interface into our code, we would need to write tests to verify the many branching `if` statements. We would also have to write code to check whether or not a given user is authenticated. 

One big problem with the interface 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? 

Another niggle is using a `boolean` field to discriminate states. We stated earlier that our types should be self-documenting, but booleans are a poor choice if we want to support this. A better way of representing this state is to use a string union:

```typescript
export interface AuthenticationStates {
  readonly state: 'UNAUTHENTICATED' | 'AUTHENTICATED';
  authToken?: string;
}
```

The biggest problem with our `AuthenticationStates` type is that only one data structure houses all of our fields. What if, after a round of testing, we found that we wanted to report system errors to the user? 

With the single interface approach, we end up with several optional fields that create more branching logic and swell the number of unit tests we need to write:

```typescript
export interface AuthenticationStates {
  readonly state: 'UNAUTHENTICATED' | 'AUTHENTICATED';
  authToken?: string;
  error?: {
     code: number;
     message: string;
  }
}
```

## Using algebraic data types and impossible states <a name="algebraic-data=types-impossible-states">

The refactored `AuthenticationStates` type below is known, in high-falutin functional programming circles, as an [algebraic data type (ADT)](https://codewords.recurse.com/issues/three/algebra-and-calculus-of-algebraic-data-types):

```typescript
export type AuthenticationStates =
  | {
      readonly kind: "UNAUTHORIZED";
    }
  | {
      readonly kind: "AUTHENTICATED";
      readonly authToken: string;
    }
  | {
      readonly kind: "ERRORED";
      readonly error: Error;
    };
```

One kind of algebraic type (but not the only one) is the [discriminated union](https://blog.logrocket.com/understanding-discriminated-union-intersection-types-typescript/), as is the `AuthenticationStates` type above. 

A discriminated union is a pattern that indicates to the compiler all the possible values a type can have. Each union member must have the same primitive field (boolean, string, number) with a unique value, known as the discriminator. 

In the example above, the `kind` field is the discriminator and has a value of `"AUTHENTICATED"`, `"UNAUTHENTICATED"`, or `"ERRORED"`. Each union member can contain fields that are only relevant to its specific `kind`. In our case, the `authToken` has no business being in any union member other than `AUTHENTICATED`. 

The code below takes this example further by using the `AuthenticationStates` type as an argument to a `getHeadersForApi` function:

```typescript
function getHeadersForApi(state: AuthenticationStates) {
  return {
    "Accept": "application/json",
    "Authorization": `Bearer ${state.??}`; // currently the type has no authToken
  }
}
```

Suppose our code doesn’t contain any logic to determine or narrow the `kind` of state our application is in. In that case, even as we type the code into our text editor, the type system is keeping us safe and not giving us the option of an `authToken`: ![Unresolved discriminated union](https://blog.logrocket.com/wp-content/uploads/2022/10/unresolved_discriminated_union-1.png) 

If we can programmatically determine that `state` can only be of `kind` `AUTHENTICATE`, then we can access the `authToken` field with impunity: ![Resolved discriminated union.](https://blog.logrocket.com/wp-content/uploads/2022/10/resolved_discriminated_union.png) 

The above code throws an error if the `state` is not of the `kind` `AUTHENTICATED`.

## Determining if union members are current (a.k.a., type narrowing) <a name="determining-union-members-current">

Throwing an exception is one way to tell the compiler which of the union members is current. Drilling down into a single union member is also known as [type narrowing](https://blog.logrocket.com/how-to-use-type-guards-typescript/). Type narrowing on a discriminated union is when the compiler knows its precise discriminator field. Once the compiler knows which discriminator field is assigned, the other properties of that union member become available. 

Narrowing the type in this way and throwing an exception is like having a test baked into our code without the ceremony of creating a separate test file.

```typescript
type AuthenticationStates =
  | {
      readonly kind: "UNAUTHORIZED";
      readonly isLoading: true;
    }
  | {
      readonly kind: "AUTHENTICATED";
      readonly authToken: string;
      readonly isLoading: false;
    }
  | {
      readonly kind: "ERRORED";
      readonly error: Error;
      readonly isLoading: false;
    };

type AuthActions =
  | { 
      type: 'AUTHENTICATING';
    }
  | { 
      type: 'AUTHENTICATE',
      payload: {
        authToken: string
    }
  }
  | {
    type: 'ERROR';
    payload: {
      error: Error;
    }
  }

function reducer(state: AuthenticationStates, action: AuthActions): AuthenticationStates {
  switch(action.type) {
    case 'AUTHENTICATING': {
      return {
        kind: 'UNAUTHORISED',
        isLoading: true
      }
    }

    case 'AUTHENTICATE': {
      return {
        kind: 'AUTHENTICATED',
        isLoading: false,
        authToken: action.payload.authToken
      }
    }

    case 'ERROR': {
      return {
        kind: 'ERRORED',
        isLoading: false,
        error: action.payload.error
      }
    }
    default:
      return state;
  }
} 
```

With discriminated unions, we can get instant feedback from our text editor and IntelliSense about which fields are currently available. 

The screenshot below shows a silly developer (me) trying to access the `authToken` while in the `ERROR` case statement. It simply is not possible: ![Accessing the authToken while in the ERROR case statement.](https://blog.logrocket.com/wp-content/uploads/2022/10/access-authToken-ERROR-case.png) 

What is also nice about the above code is that `isLoading` is not an ambiguous boolean that could be wrongly assigned and introduce an error. The value can only be `true` in the `AUTHENTICATING` state. If the fields are only available to the current union member, then less test code is required.

## Using `ts-pattern` instead of switch statements <a name="ts-pattern-instead-switch-statements">

[Switch statements](https://blog.logrocket.com/evaluating-alternatives-typescript-switch-case/) are extremely limited and suffer from a fall-through hazard that can lead to errors and bad practices. Thankfully, several npm packages can help with type-narrowing discriminated unions, and the [`ts-pattern`](https://github.com/gvergnaud/ts-pattern) [library](https://github.com/gvergnaud/ts-pattern) is an excellent choice. 

`ts-pattern` lets you express complex conditions in a single, compact expression akin to pattern matching in functional programming. There is a [tc-39 proposal](https://github.com/tc39/proposal-pattern-matching) to add pattern matching to the JavaScript language, but it is still in stage 1. 

After installing `ts-pattern`, we can refactor the code to something that resembles pattern matching:

```typescript
const reducer = (state: AuthenticationStates, action: AuthActions) =>
  match<AuthActions, AuthenticationStates>(action)
    .with({ type: "AUTHENTICATING" }, () => ({
      kind: "UNAUTHORISED",
      isLoading: true
    }))

    .with({ type: "AUTHENTICATE" }, ({ payload: { authToken } }) => ({
      kind: "AUTHENTICATED",
      isLoading: false,
      authToken
    }))

    .with({ type: "ERROR" }, ({ payload: { error } }) => ({
      kind: "ERRORED",
      isLoading: false,
      error
    }))
    .otherwise(() => state);
```

The `match` function takes an input argument that patterns can be tested against and each `with` function defines a condition or pattern to test against the input value.

## Parse, don't validate: Using a type-safe validation schema <a name="type-safe-validation-schema">

We've all written those horrible [form validation functions](https://blog.logrocket.com/react-form-validation-sollutions-ultimate-roundup/) that validate user input like the following:

```typescript
function validate(values: Form<User>): Result<User> {
  const errors = {};

  if (!values.password) {
    errors.password = 'Required';
  } else if (!/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).{8,}$/i.test(values.password)) {
    errors.password = 'Invalid password';
  }

  // etc.
  return errors;
};
```

These `validate` functions require a lot of test code to cover all the various branching `if` statements that will inevitably multiply exponentially in the function bodies. 

A better way is to analyze the data and create a type-safe schema that can execute against the incoming data at runtime. The excellent package [Zod](https://github.com/colinhacks/zod) brings runtime semantics to TypeScript without duplicating existing types. 

[Zod allows us to define schemas](https://blog.logrocket.com/schema-validation-typescript-zod/) that define the shape in which we expect to receive the data, with the bonus of being able to extract TypeScript types from the schema. We dodge a plethora of `if` statements and the need to write many tests with this approach. 

Below is a simple `UserSchema` that defines four fields. The code calls `z.infer` to extract the `User` type from the schema, which is impressive and saves a lot of duplicated typing.

```typescript
export const UserSchema = z.object({
  uuid: z.string().uuid(),
  email: z.string().email(),
  password: z.string().regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).{8,}$/),
  age: z.number().optional()
});

export type User = z.infer<typeof UserSchema>;
/* returns
type User = {
  uuid: string;
  email: string;
  password: string;
  age?: number | undefined;
}
*/
```

When we parse instead of validate, we analyze the data, create a schema that can parse the data, _and_ we get the types for free. This code is self-documenting, works at runtime, and is type-safe. 

As a bonus, Zod comes with many out-of-the-box validations. For example, the `uuid` field will only accept valid `UUID` strings, and the `email` field will only accept strings correctly formatted as emails. A custom regex that matches the application password rules is given to the `regex` function to validate the `password` field. All this happens without any `if` statements or branching code.

## Conclusion: Tests and types are not mutually exclusive

I am not saying in this post that we should stop writing tests and put all our focus into writing better types. 

Instead, we can use the type check function to check our logic directly against the application code — and in the process, we can write a hell of a lot less unit testing code in favor of writing better integration and end-to-end tests that test the whole system. By better, I do not mean we have to write a ton more tests. 

Remember the golden rule: the more code you have, the more problems you have.

---
## [LogRocket](https://lp.logrocket.com/blg/signup): Full visibility into your web and mobile apps

[![LogRocket signup](https://blog.logrocket.com/wp-content/uploads/2017/03/1d0cd-1s_rmyo6nbrasp-xtvbaxfg.png)](https://lp.logrocket.com/blg/signup)

[LogRocket](https://lp.logrocket.com/blg/signup) is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.

[Try it for free](https://lp.logrocket.com/blg/signup).