Reputation: 1201
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
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.
Upvotes: 6