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?
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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With