nitrovatter
nitrovatter

Reputation: 1201

TypeScript - How to filter the object?

I'm trying to make the right typing for the following function:

export function filter(obj: any, predicate: (value: any, key: string) => boolean) {
    const result: any = {};
    Object.keys(obj).forEach((name) => {
        if (predicate(obj[name], name)) {
            result[name] = obj[name];
        }
    });
    return result;
}

Is it possible to preserve the typings?

Upvotes: 3

Views: 5721

Answers (1)

jcalz
jcalz

Reputation: 328262

Without more specific information about use cases, I'd be inclined to do something like this:

function filter<T extends object>(
  obj: T,
  predicate: <K extends keyof T>(value: T[K], key: K) => boolean
) {
  const result: { [K in keyof T]?: T[K] } = {};
  (Object.keys(obj) as Array<keyof T>).forEach((name) => {
    if (predicate(obj[name], name)) {
      result[name] = obj[name];
    }
  });
  return result;
}

We want filter() to accept an obj parameter of generic type T extends object, meaning that you will only filter object-types and not primitives, and you want the compiler to keep track of the actual key-value relationship as T and not widen it all the way to object. The predicate callback will therefore need to be something that accepts value and key parameters that are applicable for T; so we make it a generic callback in the type K extends keyof T of the key parameter, where the value is the corresponding property value type T[K]. See the documentation for keyof and lookup types for more info on that notation.

For the result, we want to return an object whose properties are the same as T but optional, since we don't know which properties will actually exist. This can be represented as the mapped type { [K in keyof T]? T[K] }, also known as Partial<T>.

One issue is that I had to use a type assertion to tell the compiler that I expect Object.keys(obj) to be Array<keyof T> (an array of the keys of T) instead of just string[]. This expectation may be violated, which is why Object.keys() returns string[] in the first place; see this other SO question for an explanation of that. I think it's reasonable to make this assumption here, but I'll show you a way it can lead to runtime errors if the assumption is violated.


First let's test the desired behavior:

const obj = filter({ a: "hello", b: 123, c: true }, (v, k) => k === "b");
/* const obj: {
    a?: string | undefined;
    b?: number | undefined;
    c?: boolean | undefined;
} */
console.log(obj); // {b: 123}

That looks good; the compiler is happy with the parameters to filter() and returns a value with optional properties.

Here's the bad case:

interface Foo {
  x: string,
  y: string,
}
interface Bar extends Foo {
  z: number;
}
const bar: Bar = { x: "hello", y: "goodbye", z: 100 };
const foo: Foo = bar; // acceptable because Bar extends Foo
filter(foo, (v, k) => v === v.toLowerCase() ); // compiles fine, but
// 💥 RUNTIME TypeError: v.toLowerCase is not a function

The compiler does not realize that foo has a number-valued property because we've widened bar to Foo, but the predicate callback really relies on v being a string. This compiles, and you get a runtime error. If this possibility concerns you, then you might want a more strict typing of filter() that forces predicate to really take values of type (value: unknown, key: PropertyKey), but that will be more annoying to use. It really depends on use cases.

Playground link to code

Upvotes: 6

Related Questions