DEV Community

Cover image for Typed Filtering with Generics in Typescript
Volodymyr Yepishev
Volodymyr Yepishev

Posted on • Originally published at linkedin.com

Typed Filtering with Generics in Typescript

Header image was generated by AI given the article title. Couldn't imagine it better myself.

// NOTE: all links to source code can be found at the end of the article.

Imagine you have an array of numbers:

const arrayOfNumbers: number[] = [1, 2, 3, 4, 5, 6];
Enter fullscreen mode Exit fullscreen mode

What should be the type of the array you get when you filter it? Let's say you filter out all numbers that do not equal to 2:

const filteredArray = arrayOfNumbers.filter((n) => n === 2);
Enter fullscreen mode Exit fullscreen mode

The resulting inferred type is Array<number>, while realistically it can be narrowed to Array<2>, this narrowing of resulting type is what the article is about.

Similarly, provided you have an array of users:

interface User {
  details: {
    name: string;
  };
}

const users: User[] = [
  {
    details: {
      name: "Bob",
    },
  },
  {
    details: {
      name: "non Bob",
    },
  },
];

const arraysOfBobs = users.filter((user) => user.details.name === "Bob");
Enter fullscreen mode Exit fullscreen mode

We could argue the resulting array is a subtype of Array<User>, where the type of name is no longer string, but literally Bob:

interface Bob {
  details: {
    name: "Bob";
  };
}
Enter fullscreen mode Exit fullscreen mode

Let us see how we can perform this type narrowing with the amazing features of Typescript 4.

In order to create a custom filter that narrow down resulting array types, we would need to write 3 powerful generics:

  • first and foremost, a generic capable of generating all permutations of all possible property paths in an object, so we can perform filtering of objects with nested objects;
  • then a generic which can extract the type of a property in an object by the path produced by the first generic;
  • and finally, a generic that can swap field types of a property represented by a string path provided by the first generic.

I wrote an article how to write a property path generator with generics earlier. But it is too powerful to have a derived types using it, since it is infinite and makes typescript compiler go nuts. To nerf it down a little, a limit can be set how far the generic should go down the nested paths. Let's do 99 as default and leave room to pass a number.

More about how to create number limits with generics can be found in an article by Acid Coder.

So thus we get

type DEFAULT_DEPTH_LEVEL = 99;

type Primitive = string | number | bigint | boolean | undefined | symbol;

type PropertyPath<
  TYPE,
  DEPTH extends number = DEFAULT_DEPTH_LEVEL,
  LEVEL extends number[] = [],
  PREFIX = ""
> = {
  [KEY in keyof TYPE]: LEVEL["length"] extends DEPTH
    ? never
    : KEY extends "valueOf" | "toString"
    ? never
    : TYPE[KEY] extends Primitive | Array<unknown>
    ? `${string & PREFIX}${string & KEY}`
    :
        | `${string & PREFIX}${string & KEY}`
        | PropertyPath<
            TYPE[KEY],
            DEPTH,
            [1, ...LEVEL],
            `${string & PREFIX}${string & KEY}.`
          >;
}[keyof TYPE];
Enter fullscreen mode Exit fullscreen mode

So now we have a property path generic that generates all possible paths within an object.

type UserPaths = PropertyPath<User>; // 'details' | 'details.name'
Enter fullscreen mode Exit fullscreen mode

Now we are ready to create a generic to probe objects and extract the type of a property represented by a path generated from our PropertyPath generic:

type DeepNested<
  TYPE,
  DEPTH extends number,
  PATH extends PropertyPath<TYPE, DEPTH>,
  LEVEL extends number[] = []
> = {
  [KEY in keyof TYPE]: LEVEL["length"] extends DEPTH
    ? never
    : PATH extends `${string & KEY}.${infer REMAINING_PATH}`
    ? // @ts-ignore
      DeepNested<TYPE[KEY], DEPTH, REMAINING_PATH, [1, ...LEVEL]>
    : KEY extends PATH
    ? TYPE[KEY]
    : never;
}[keyof TYPE];
Enter fullscreen mode Exit fullscreen mode

Note how we unpack the path by splitting it layer by layer, until we either reach the limit of depth or arrive at the sought property:

type NameType = DeepNested<User, 2, "details.name">; // that's a string
Enter fullscreen mode Exit fullscreen mode

Generic for substituting a type of a property represented by a path follows the similar approach of having depth, level and a new type, which the given property should get:

type DeepSubstituted<
  TYPE,
  DEPTH extends number,
  PATH extends PropertyPath<TYPE, DEPTH>,
  SUBSTITUTION,
  LEVEL extends number[] = []
> = {
  [KEY in keyof TYPE]: LEVEL["length"] extends DEPTH
    ? never
    : PATH extends `${string & KEY}.${infer REMAINING_PATH}`
    ? DeepSubstituted<
        TYPE[KEY],
        DEPTH,
        // @ts-ignore
        REMAINING_PATH,
        SUBSTITUTION,
        [1, ...LEVEL]
      >
    : KEY extends PATH
    ? SUBSTITUTION
    : TYPE[KEY];
};
Enter fullscreen mode Exit fullscreen mode

It is time for the final touch: create a filter function which performs swapping types for nested fields. Ideally we would need to implement a function which takes a predicate add then throws it into the filter function of the array, but let's simplify things a bit and instead implement filtering by a value. That means we take an array of objects, path to the property by which we filter and the value, by which we will perform the filtering, and which will become the new type of the property represented by the path.

Also, for the sake of simplicity let's assume we do not have optional fields and all paths are guaranteed.

Given these conditions we can create a function for extracting a value from a given property path, which would look the following way:

function getNestedValue<TYPE, DEPTH extends number>(
  a: TYPE,
  p: PropertyPath<TYPE, DEPTH>
): unknown {
  return (<string>p)
    .split(".")
    .reduce<unknown>(
      (c: Record<string, unknown> | unknown, p: string) =>
        (<Record<string, unknown>>c)[p],
      a
    );
}
Enter fullscreen mode Exit fullscreen mode

The typed filter, which we're aiming for would use it for value extraction would then take full advantage of all our generics and extractor function. We will be accepting the type of object in the array, a path to one of its properties, a substitution value, which has to be a narrowed type of the property represented by the given path, and an optional parameter of depth for the typescript compiler not to go insane over potentially infinite depth:

function typeFilter<
  TYPE,
  PATH extends PropertyPath<TYPE, DEPTH>,
  SUBSTITUTION extends DeepNested<TYPE, DEPTH, PATH>,
  DEPTH extends number = DEFAULT_DEPTH_LEVEL
>(
  arr: TYPE[],
  field: PATH,
  compareWith: SUBSTITUTION
): Array<DeepSubstituted<TYPE, DEPTH, PATH, SUBSTITUTION>> {
  return arr.filter(
    (a: TYPE) => getNestedValue<TYPE, DEPTH>(a, field) === compareWith
  ) as Array<DeepSubstituted<TYPE, DEPTH, PATH, SUBSTITUTION>>;
}
Enter fullscreen mode Exit fullscreen mode

Now getting an array of users named Bob is as easy as:

const arrayOfBobs = typeFilter<User, "details.name", "Bob">(
  users,
  "details.name",
  "Bob"
);
arrayOfBobs[0].details.name; // name is of narrowed type 'Bob' now, not the broader type string
Enter fullscreen mode Exit fullscreen mode

Yet, that is a quite complex filter for infinitely deep objects, but what if we want to filter only arrays with primitives? It is even easier in this case because we don't need to leverage the power of recursion in generics:

function primitiveTypeFilter<TYPE extends Primitive, SUBSTITUTION extends TYPE>(
  arr: TYPE[],
  compareWith: SUBSTITUTION
): SUBSTITUTION[] {
  return arr.filter((a) => a === compareWith) as SUBSTITUTION[];
}
Enter fullscreen mode Exit fullscreen mode

Now to get an array of, let's say, 2s we could simply pass the type and value to the generic:

const arrayOf2s = primitiveTypeFilter<number, 2>([1, 2, 3, 4, 5], 2); // get Array<2> type
Enter fullscreen mode Exit fullscreen mode

Isn't it amazing how powerful typescript generics are? It is incredible what amazing possibilities for typing they provide.

The source code for this article is available in the monorepo or piled up in the playground.

The filters as an npm package are available on npm.

Latest comments (5)

Collapse
 
tylim88 profile image
Acid Coder • Edited

As for property path, you don't need to specify the level of depth, here is the simplified version you can try

type DeepKey<
    T,
    K extends keyof T = keyof T
> = K extends string | number | `${number}` // also handle numeric key (wide type only)
    ? T[K] extends infer R
        ? R extends Record<string, unknown>
            ?
                    | `${K}`
                    | `${K}/${DeepKey<R>}`
            : `${K}`
        : never // impossible route
    : never // impossible route

type DeepValue<
    T,
    P extends DeepKey<T>,
> = P extends `${infer K}/${infer Rest}`
    ? T[K & keyof T] extends infer S
        ? Rest extends DeepKey<S>
                ? DeepValue<S, Rest>
                : never // impossible route
        : never // impossible route
    : T[P & keyof T]
Enter fullscreen mode Exit fullscreen mode

playground

there is one flaw, it cannot handle numeric literal key(still unsure this is possible or not), the wide numeric key type however is possible ( as shown in the playground)

Collapse
 
bwca profile image
Volodymyr Yepishev

That's amazing!

Collapse
 
tylim88 profile image
Acid Coder

ok I did some patch note digging and found that it is possible to support numeric literal key now

dev.to/tylim88/typescript-generate...

Thread Thread
 
bwca profile image
Volodymyr Yepishev

Looks convenient if you have a record with number keys.

Collapse
 
tylim88 profile image
Acid Coder • Edited

you can do

function primitiveTypeFilter<
  TYPE extends Primitive[],
  SUBSTITUTION extends TYPE extends (infer R)[] ? R : never
>(
  arr: TYPE,
  compareWith: SUBSTITUTION
): SUBSTITUTION[] {
  return arr.filter(a => a === compareWith
  ) as SUBSTITUTION[];
}
const arrayOf2s = primitiveTypeFilter([1, 2, 3, 4, 5], 2)
Enter fullscreen mode Exit fullscreen mode

so you could drop the <number, 2>

playground

// ====================================

there is some other problems:

  1. if the element you search for is not exist, hence the result should be an empty array(solving problem 3 also solve this, it simply stop you from using value with type that is not exist)

  2. another problem is runtime value, normally the runtime value is not literal type, it is wide type like number[], in this case we have to also return number[]. (This problem is trivial compare to problem 3, so I do not attach the solution)

  3. then finally if it involve variable you need be aware of weird problem like this stackoverflow.com/questions/744674...
    where the way generic infer variable type is depend on how you annotate your type in variable, an easy way is to explicitly assert as const(require more user effort), an implicit solution exist(not shown in this playground)

Image description

the failed case is the one that need implicit narrowing solution for optimal developer experience