Strict & Weak Exhaustive Checks in TypeScript: Nuke your app at runtime for fun and profit!

This article assumes you are familiar with the never type, exhaustive checks, and the concept of failing fast. If you aren't yet, or want a refresher, here are some resources to get up to speed:

Now I'm going to introduce you to two functions I wind up bringing into most TypeScript applications I work on, isStrictNever and isWeakNever

isStrictNever

export const isStrictNever = (x: never): never => {
  throw new Error(`Never case reached with unexpected value ${x}`);
};

This function will throw if it is ever called at runtime, and our compile-time checks ensure that this function should never be called.

isWeakNever

export const isWeakNever = (x: never): void => {
  console.error(`Never case reached with unexpected value ${x} in ${new Error().stack}`);
};

This function will only log at runtime, while still providing compile-time checks ensuring that this function should never be called.

Using isStrictNever to fail fast

Out of the two functions, isStrictNever is preferred in most cases because it helps us fail fast. One place I always use this is if I'm performing any mapping or checks at the database layer. Data consistency at runtime is key, and can easily cause your application to fall into an unpredictable state if the system attempts to continue operating with bad data.

Here's an example:

type Article = {
  author: string;
  body: string;
  title: string;
  type: ArticleType;
  publishedAt: Date;
};

type ArticleType = 'STANDARD' | 'SERIES';

export const selectLatestArticle = async (): Promise<Article | undefined> => {
  const res = await pool.query(`
    SELECT id, author, title, body, type_id, published_at 
    FROM articles
    ORDER BY published_at
    LIMIT 1;
  `);
  return res.rows.map(mapArticleRowToArticle).shift();
};

type ArticleRow = {
  author: string;
  body: string;
  title: string;
  type_id: ArticleTypeId;
  published_at: Date;
};

type ArticleTypeId = 1 | 2;

const mapArticleRowToArticle = ({published_at, type_id, ...row}: ArticleRow): Article => ({
  ...row,
  publishedAt: published_at
  type: mapArticleTypeIdToTypeString(type_id),
});

const mapArticleTypeIdToTypeString = (typeId: ArticleTypeId): ArticleType => {
  switch (typeId) {
    case 1: {
      return 'STANDARD';
    }
    case 2: {
      return 'SERIES';
    }
    default: {
      return isStrictNever(typeId);
    }
  }
};

Now, if I had a new article type id in the database, but forget to update the code to match, my application will crash whenever it retrieves an article using this new type.

You might think that sounds worse, but it's actually much better. This bug will likely cause end to end tests to fail completely. Because we failed fast, this change is unlikely to make it to production. Even if it does, we can easily track 50x coded HTTP responses in our observability system, we raise alerts when error rates spike, meaning our time to detection and time to recovery will be fast.

If instead we had simply let mapArticleTypeIdToTypeString return undefined, the impact might have been less obvious: it likely would have gone to production, and we would be dealing with disappointed product managers, a product that looks less professional, and have to prioritise bug tickets against new feature work. Given the option, I would much rather fail and fix up front.

Not to mention the fact that latent bugs are vectors for new bugs to interact and cause additional unexpected behaviours, further undermining the reliability of our application.

But a simple isStrictNever can save us all of that pain. So why would we ever use isWeakNever?

Using isWeakNever at the intersection of third party domains

Sometimes, we don't fully control the flow of data nor the implementation of types through our system. In these cases, we can use isWeakNever to create type systems that still help us enforce effective compile time checking for the parts of code we do control, while passing through unexpected runtime types from the code we do not control.

I often use this in a redux reducer, for example:

type CountState = {
  count: number;
};

const initialState: CountState = {
  count: 0,
};

type IncrementAction = {
  type: 'INCREMENT';
}

type DecrementAction = {
  type: 'DECREMENT';
}

type CountAction = IncrementAction | DecrementAction;

export const reducer = (state: CountState = initialState, action: CountAction): CountState => {
  switch (action.type) {
    case 'INCREMENT': {
      return { count: state.count + 1 };
    }
    case 'DECREMENT': {
      return { count: state.count - 1 };
    }
    default: {
      isWeakNever(action);
      return state;
    }
  }
};

Now if I introduce a new count action:

type SquareAction = {
  type: 'SQUARE';
};

type CountAction = IncrementAction | DecrementAction | SquareAction;

But neglect to add a new case to the reducer, I will receive a compile time error because action has a possible value in the default case.

This compile time check gives me certainty that my implementation has completeness, while allowing the app to continue running when Redux sends its @@/INIT action, a middleware dispatches an action, or even if we have multiple reducers combined together.

Yes, the type system is lying to us a little, but it is a known, controlled, and measured lie. Logging the uses of isWeakNever gives us visibility into the things that slip through.

While Phryneas (AKA Lenz Weber) levies some valid criticisms towards the discriminated union approach to Redux reducers in his article, I believe this approach alleviates his concerns, while making the holes in the type system much more explicit.

This approach also enforces completeness through exhaustive checks, while his approach leaves these checks behind only to focus on payload type hinting.

In my opinion, exhaustive checks AND payload type hinting together are worthwhile, given an explicit indicator that the type system is incomplete, as usage of isWeakNever does.

Have another opinion on the matter? Share your thoughts in the comments below!