Home > front end >  Is it possible to make a typesafe function taking an array of a union type and returning an array of
Is it possible to make a typesafe function taking an array of a union type and returning an array of

Time:11-06

Is it possible to make a typesafe function taking an array of a union type and returning an array of only one of the types in the union?

Something like getOnlyBooksOrOnlyRocks below (which is not correct TS syntax).

  interface Book {
    age: number;
    pages: number;
  }

  interface Rock {
    age: number;
    shape: string;
  }

  type BookOrRock = (Book & { shape?: never }) | (Rock & { pages?: never });

  const isBook = (x: BookOrRock): x is Book => 'pages' in x;

  const getOnlyBooksOrOnlyRocks = <T extends BookOrRock>(array: BookOrRock[]) => {
    const filtered = [] as T[];

    for (const el of array) {
      if (T extends Book && isBook(el)) filtered.push(el);
      else if (T extends Rock && !isBook(el)) filtered.push(el);
    }

    return filtered;
  };

  const booksAndRocks: BookOrRock[] = [
    { age: 1, pages: 100 },
    { age: 1e9, shape: 'round' },
  ];

  const rocks = getOnlyBooksOrOnlyRocks<Rock>(booksAndRocks);

I would expect the variable rocks to be inferred as a Rock[].

CodePudding user response:

The problem with something like getOnlyBooksOrOnlyRocks<Rock>(booksAndRocks) is that TypeScript's static type system, which includes generic type arguments like <Rock>, is erased when the code is transpiled into JavaScript. So the actual code that runs would look like getOnlyBooksOrOnlyRocks(booksAndRocks), with no <Rock> in there to tell your program which type you want.

The only way this could possibly work is to pass a value that specifies the type you want into getOnlyBooksOrOnlyRocks(), from which the generic type argument could be inferred by the compiler.


For example, if you're willing to pass in a type guard function such as isBook or isRock:

const isBook = (x: BookOrRock): x is Book => 'pages' in x;
const isRock = (x: BookOrRock): x is Rock => 'shape' in x;

like this:

const rocks = getOnlyBooksOrOnlyRocks(isRock, booksAndRocks);

then you can define getOnlyBooksOrOnlyRocks like this:

const getOnlyBooksOrOnlyRocks = <T extends BookOrRock>(
    isType: (x: BookOrRock) => x is T,
    array: BookOrRock[]
): T[] => {
    const filtered: T[] = [];
    for (const el of array) {
        if (isType(el)) filtered.push(el);
    }
    return filtered;
};

That compiles without error. You can see that the type of isType is (x: BookOrRock) => x is T, so hopefully the compiler can infer T from the type of the function passed in.

Note that you could implement this more simply as

const getOnlyBooksOrOnlyRocks = <T extends BookOrRock>(
    isType: (x: BookOrRock) => x is T,
    array: BookOrRock[]
): T[] => {
    return array.filter(isType); 
};

because one of TypeScript's call signatures for the filter() array method captures the idea that a type guard callback should narrow the type of the filtered array.


Let's make sure that it works:

const booksAndRocks: BookOrRock[] = [
    { age: 1, pages: 100 },
    { age: 1e9, shape: 'round' },
];

const rocks = getOnlyBooksOrOnlyRocks(isRock, booksAndRocks);
// const rocks: Rock[]
console.log(rocks.map(r => r.shape.toUpperCase()).join(", ")) // "ROUND"

const books = getOnlyBooksOrOnlyRocks(isBook, booksAndRocks);
// const books: Book[]
console.log(books.map(b => b.pages.toFixed(2)).join(", ")) // "100.00"

Looks good! The compiler infers Rock for T in the first call because isRock has return type x is Rock, and it infers Book for T in the second call has return type x is Book.


There are other ways to do this, but all of them will involve some actual value argument that can be used to distinguish the "filter Rocks" case from the "filter Books" case. If you want to call getOnlyBooksOrOnlyRocks("Rock", booksAndRocks) instead of getOnlyBooksOrOnlyRocks(isRock, booksAndRocks), you could set up a mapping object from strings like "Rock" and "Book" to the relevant type guard functions. But I won't belabor the point by detailing how that would be implemented.

Playground link to code

  • Related