Array operations over union of arrays

November 10th 2023 TypeScript

I have already written about union types in TypeScript in the past and explained how generic functions can be used with any underlying type and still preserve the original type as their return value:

export function reverseNameGeneric<T extends TypeDef.Person>(person: T): T {
  let reversePerson = Object.assign({}, person);
  reversePerson.firstName = person.firstName.split("").reverse().join("");
  reversePerson.lastName = person.lastName.split("").reverse().join("");
  return reversePerson;
}

However, unless you're using the latest minor version of TypeScript (5.2 or later), this will not work for array operations on a common union type field.

Let's try it with the following set of types:

export type ChapterType = "prologue" | "chapter" | "epilogue";

export interface ComicChapter {
  title: string;
  pages: number;
  type: ChapterType;
}

export interface Comic {
  author: string;
  artist: string;
  title: string;
  chapters: ComicChapter[];
}

export interface AudiobookChapter {
  title: string;
  duration: number;
  type: ChapterType;
}

export interface Audiobook {
  author: string;
  narrator: string;
  title: string;
  chapters: AudiobookChapter[];
}

export type Book = Comic | Audiobook;
export type Chapter = ComicChapter | AudiobookChapter;

In short, we have two different types of books with slightly different fields. Both have a chapters array. The items in it again slightly differ between the two book types.

Let's write a generic function that returns a prologue chapter for the given book:

export function getPrologue<T extends Book>(
  book: T
): T["chapters"][0] | undefined {
  return book.chapters.find((chapter) => chapter.type === "prologue");
}

The return type uses indexed access types to return the correct item type in chapters array for the input type. This means that the function is going to return a ComicChapter for a Comic and an AudiobookChapter for an Audiobook.

This method works just fine in TypeScript 5.2 or later, but not in earlier versions. If you try it with TypeScript 5.1.6, you'll get the following error on the find method:

This expression is not callable. Each member of the union type { <S extends ComicChapter>(predicate: (value: ComicChapter, index: number, obj: ComicChapter[]) => value is S, thisArg?: any): S; (predicate: (value: ComicChapter, index: number, obj: ComicChapter[]) => unknown, thisArg?: any): ComicChapter; } | { ...; } has signatures, but none of those signatures are compatible with each other.

You can check the corresponding TypeScript issue for details, but essentially the compiler is having trouble recognizing that the find method can be called on the union of the two different arrays.

The simplest solution is to use the latest TypeScript compiler in your project. If you can't do that because of other dependencies, you'll need to find a workaround. Although you could use type assertions to avoid the type error, there are ways to make the code compile while preserving type safety.

My first attempt was the following:

export function getPrologueChapter<T extends Chapter>(
  chapters: Array<T>
): T | undefined {
  return chapters.find((chapter) => chapter.type === "prologue");
}

export function getPrologue<T extends Book>(
  book: T
): T["chapters"][0] | undefined {
  return getPrologueChapter(book.chapters);
}

The getProloguChapter helper function that operates directly on the array worked just fine, but unfortunately, trying to call it from the original getPrologue function still failed, just with a different error:

Argument of type ComicChapter[] | AudiobookChapter[] is not assignable to parameter of type ComicChapter[]. Type AudiobookChapter[] is not assignable to type ComicChapter[]. Property pages is missing in type AudiobookChapter but required in type ComicChapter.

If the two arrays had contained the same type of items, this would have worked. But since they are different, it didn't. If I was fine with passing the chapter array to the method instead of the parent class, I could still use the getPrologueChapter helper function directly.

But I wanted to find a working workaround for the original function, and in the end I succeeded with the following:

export function getPrologue<T extends Book>(
  book: T
): T["chapters"][0] | undefined {
  return book.chapters
    .map((chapter) => ({ isPrologue: chapter.type === "prologue", chapter }))
    .find((chapter) => chapter.isPrologue)?.chapter;
}

This time, I avoided calling the find method on the union array type. Instead, I created a temporary array with an additional flag in each item, on which I could use the find method in the next step.

This approach has a downside: I had to change the original JavaScript code. I am now creating additional temporary objects to hold the calculated values. For large arrays, this could make it noticeably less efficient.

You can check the code in my GitHub repository. The first commit contains the original code, which works with the latest TypeScript compiler. The last commit contains the working workaround for older TypeScript versions.

TypeScript type system is very complex and flexible, but even after over 10 years of development, you could still encounter working JavaScript code that can't be written using types in TypeScript. Fortunately, the language is still evolving and the code I presented in this post can now be written in a type safe manner when using the latest version of the compiler.

Get notified when a new blog post is published (usually every Friday):

Copyright
Creative Commons License