Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(collections): ensure pick doesn't generate missing properties as undefined #5926

Merged
merged 6 commits into from
Sep 12, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion collections/pick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,7 @@ export function pick<T extends object, K extends keyof T>(
obj: Readonly<T>,
keys: readonly K[],
): Pick<T, K> {
return Object.fromEntries(keys.map((k) => [k, obj[k]])) as Pick<T, K>;
const result = {} as Pick<T, K>;
for (const key of keys) if (key in obj) result[key] = obj[key];
return result;
}
28 changes: 28 additions & 0 deletions collections/pick_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ Deno.test({
},
});

Deno.test({
name:
"pick() returns a new object without properties missing in the original object",
fn() {
// deno-lint-ignore no-explicit-any
const obj = { a: 5, b: 6, c: 7, d: 8 } as any;
const picked: { a: 5; x?: 5; y?: 5 } = pick(obj, ["a", "x", "y"]);

assertEquals(picked, { a: 5 });
assertNotStrictEquals(picked, obj);
},
});

Deno.test({
name:
"pick() returns a new object from the provided object with the provided keys",
Expand All @@ -37,3 +50,18 @@ Deno.test({
assertNotStrictEquals(picked, obj);
},
});

Deno.test({
name:
"pick() returns a new object with own properties that is non-own in the original object)",
fn() {
// deno-lint-ignore no-explicit-any
const obj = { a: 5, b: 6, c: 7, d: 8 } as any;
const picked: { toString: typeof Object.prototype.toString } = pick(obj, [
"toString",
]);

assertEquals(picked, { toString: Object.prototype.toString });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I personally don't think this is intentionally in this way.. (The check with Object.hasOwn meets the users' expectation better in my view)

@lambdalisue What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Considering the original purpose, I hadn't envisioned a use case where toString would be picked. Therefore, I think @kt3k's suggestion is a good one. However, since the implementation currently references obj[k], changing it to Object.hasOwn would be a breaking change. Additionally, in terms of type expression, it's written as K in keyof T, which means it allows something like toString (the following code does not result in a type error).

import { pick } from "jsr:@std/collections/pick";
import { omit } from "jsr:@std/collections/omit";

class A {
  constructor(public a: string, public b: string, public c: string) {}

  foo(): string {
    return "hello";
  }
}

const obj = new A("a", "b", "c");

const p = pick(obj, ["a", "c", "foo"]);
const o = omit(obj, ["b"]);

console.log(obj.foo());
console.log(p.foo());
console.log(o.foo());

This might be beyond the scope of this PR, but I realized that o.foo() does not work in the code above. Thus, currently there is an inconsistency between pick and omit. Given this situation, we need to choose between the following options:

  1. Restrict the targets of pick and omit to Object.hasOwn (and adjust the type so it expresses this correctly).
  2. Fix the implementation of omit so that o.foo() does not result in a runtime error.

Personally, I prefer option 1, but I’m not sure how to express this with types.

Copy link
Contributor Author

@yuhr yuhr Sep 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually agree with @kt3k, the test case is just to avoid introducing accidental breaking change in the meantime.

I appreciate all the consideration given by @lambdalisue. Especially, the inconsistency between pick and omit is one of the reasons I proposed #5927; the most beautiful solution I think is to only operate on own properties and copy the prototype to the result, which is kind of a merge of option 1 and 2. Try replacing the imports with the following code and rerun your example code:

const pick = <T extends object, K extends keyof T>(
	obj: Readonly<T>,
	keys: readonly K[],
): Pick<T, K> => {
	const result = Object.create(Object.getPrototypeOf(obj))
	for (const key of keys) {
		const descriptor = Object.getOwnPropertyDescriptor(obj, key)
		if (descriptor) Object.defineProperty(result, key, descriptor)
	}
	if (!Object.isExtensible(obj)) Object.preventExtensions(result)
	return result
}

const omit = <T extends object, K extends keyof T>(
	obj: Readonly<T>,
	keys: readonly K[],
): Omit<T, K> => {
	const result = Object.create(Object.getPrototypeOf(obj))
	const excludes = new Set(keys)
	for (const key of Reflect.ownKeys(obj)) {
		if (!excludes.has(key as K)) {
			const descriptor = Object.getOwnPropertyDescriptor(obj, key)!
			Object.defineProperty(result, key, descriptor)
		}
	}
	if (!Object.isExtensible(obj)) Object.preventExtensions(result)
	return result
}

But yes, the parameter types will be looser and the return types will be stricter than desired this way, though still sound.

Anyways it's beyond the scope, so I suggest moving to another issue.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, ok. The current check (key in obj) works good for the case of the above class A example (foo method should be picked in that case).

I'm now in favor of landing this fix.

However, I'm still not sure we should include this particular example (pick(obj, ["toString"])) as a test case, as this causes type error if we don't type cast obj to any. I think we can consider this particular behavior something like undefined/not decided behavior.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about just removing this test case and revisit later when someone comes up with something better?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not opposed to it, but just curious, is the concept of “undefined behavior” common in the standard library? If we decide to make this UB, there has to be an explicit notice in the JSDoc comment.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not common to make something explicitly undefined behavior in the standard library, but I think we are not ready to decide about this behavior, and also this looks out of scope of this PR.

In my view, in the below example, picking foo looks fine, but picking toString looks strange as it doesn't match its type (it's actually causes a type error).

import { pick } from "@std/collections";
class A {
  foo() {
  }
  bar() {
  }
}
const a = new A();
pick(a, ["foo"]);
pick(a, ["toString"]);

assertNotStrictEquals(picked, obj);
},
});