Narrowing union types with Typescript predicates and assertions

Imagine making an API request to an endpoint that provides data organized into folders. Requesting the data from a specific folder would return items that the folder contains as well as additional folders nested directly under the folder in question.

type Response = {
  contents: (Folder | Item)[];
  id: string;
  name: string;
}

A folder navigation UI would need to split apart the Folders from the Items in order to render properly. This could easily be solved in a component by using filter to get rid of the undesired items at the point of use but at the cost of iterating the set for each use.

Also, what if I needed to split other kinds of arrays? For example, what if I had a list of users with an isActive property, and I wanted to split those into [activeUsers, inactiveUsers]? The underlying problem is pretty general so a small utility function would help here.

Problem: Given an array of two kinds of things (T and U), let’s split them apart and return each subsection of the array in a tuple ([T[], U[]]).

const arraySplitter = <T, U>(
  arrayToSplit: (T | U)[], 
    splitFn: (item: T | U) => boolean
): [T[], U[]] => {
  const setOne: T[] = [];
    const setTwo: U[] = [];
  arrayToSplit.forEach(item => {
    if(splitFn(item)){
          // Argument of type 'T | U' is not assignable to parameter of type 'T'
      setOne.push(item); // ERROR
    } else {
          // Argument of type 'T | U' is not assignable to parameter of type 'U'
      setTwo.push(item); // ERROR
    }
  })
  return [setOne, setTwo];
}

The functions works, but the compiler can’t actually tell if the splitFn is narrowing the types correctly so it complains. There are two primary ways to provide it additional context.

Solution 1: Type assertions

The most obvious solution is just to tell the compiler what’s happening via a type assertion. These assertions are done using the as keyword.

const arraySplitter = <T, U>(
  arrayToSplit: (T | U)[], 
    splitFn: (item: T | U) => boolean
): [T[], U[]] => {
  const setOne: T[] = [];
    const setTwo: U[] = [];
  arrayToSplit.forEach(item => {
    if(splitFn(item)){
      setOne.push(item as T); // the T's go here
    } else {
      setTwo.push(item as U); // the U's go here
    }
  })
  return [setOne, setTwo];
}

Generally, type assertions are discouraged as they can easily reduce the effectiveness of the compiler by overriding it erroneously. That’s true here as well: providing a splitFn that doesn’t accurately or exhaustively split the types will cause problems downstream.

Solution 2: Type Predicates

In programming, a “predicate” is a function with a single parameter that returns either true or false. Typescript uses predicates to narrow types. It’s annotated with a special return description using the is keyword.

function itemIsType(item: unknown): item is T {
 // return true if item passes test, otherwise false
}

The splitFn above is already functioning as predicate, so updating the type definition will clear the error and narrow the type for the compiler.

const arraySplitter = <T, U>(
  arrayToSplit: (T | U)[], 
    splitFn: (item: T | U) => item is T
): [T[], U[]] => {
  const setOne: T[] = [];
    const setTwo: U[] = [];
  arrayToSplit.forEach(item => {
    if(splitFn(item)){
      setOne.push(item); 
    } else {
      setTwo.push(item); 
    }
  })
  return [setOne, setTwo];
}

This clears the error as well, but there are a couple of caveats:

Caveat 1: This approach makes more sense when splitting apart an array into two strongly differentiated subtypes represented in the type system. In the second example (the active users) the following works, but the predicate function has a hard time describing the intent when narrowing within a type:

type User = {
  name: string;
    isActive: boolean;
}
const [activeUsers, inactiveUsers] = arraySplitter(users, (user): user is User => user.isActive);

Here it would probably be better to create ActiveUser and InactiveUser types. However, certain subgroupings resist type descriptions. There’s no easy way that I know of to break apart a list of users into newUsers and oldUsers where the differentiator is a test for if an accountCreated timestamp happens before or after a specific time.

Caveat 2: While the type safety here is stronger than in solution 1, there’s still no guarantee that the predicate is error-free in its differentiation logic. A poorly constructed predicate function could still result in compiler errors downstream if it fails to accurately differentiate between the types.

Conclusion

As neither of these two approaches guarantees type safety, I haven’t arrived at a strong conclusion for recommending use. I’ve opted for using type assertions given that it feels more like idiomatic Javascript to me and there’s less pressure to create new types just to satisfy the compiler when operating on single-type arrays. In less generalized utilities, I’d probably opt for predicates. Which style do you use?