diff --git a/src/compat/_internal/arrayLikeKeys.ts b/src/compat/_internal/arrayLikeKeys.ts new file mode 100644 index 000000000..4050b0ca4 --- /dev/null +++ b/src/compat/_internal/arrayLikeKeys.ts @@ -0,0 +1,42 @@ +import { isIndex } from './isIndex'; +import { isArguments } from '../predicate/isArguments'; +import { isTypedArray } from '../predicate/isTypedArray'; +import { times } from '../util/times'; + +/** + * Creates an array of the enumerable property names of the array-like `value`. + * + * @private + * @param {ArrayLike} value The value to query. + * @param {boolean} inherited Specify returning inherited property names. + * @returns {string[]} Returns the array of property names. + */ +export function arrayLikeKeys(value: ArrayLike, inherited: boolean): string[] { + const isArr = Array.isArray(value); + const isArg = !isArr && isArguments(value); + const isBuff = !isArr && !isArg && Buffer?.isBuffer(value); + const isType = !isArr && !isArg && !isBuff && isTypedArray(value); + const skipIndexes = isArr || isArg || isBuff || isType; + const result = skipIndexes ? times(value.length, String) : []; + const length = result.length; + + for (var key in value) { + if ( + (inherited || Object.hasOwn(value, key)) && + !( + skipIndexes && + // Safari 9 has enumerable `arguments.length` in strict mode. + (key == 'length' || + // Node.js 0.10 has enumerable non-index properties on buffers. + (isBuff && (key == 'offset' || key == 'parent')) || + // PhantomJS 2 has enumerable non-index properties on typed arrays. + (isType && (key == 'buffer' || key == 'byteLength' || key == 'byteOffset')) || + // Skip index properties. + isIndex(key, length)) + ) + ) { + result.push(key); + } + } + return result; +} diff --git a/src/compat/_internal/arrayProto.ts b/src/compat/_internal/arrayProto.ts new file mode 100644 index 000000000..e0dbfc1ab --- /dev/null +++ b/src/compat/_internal/arrayProto.ts @@ -0,0 +1 @@ +export const arrayProto: any = Array.prototype; diff --git a/src/compat/_internal/isIndex.ts b/src/compat/_internal/isIndex.ts index 79260c32c..fabc1808b 100644 --- a/src/compat/_internal/isIndex.ts +++ b/src/compat/_internal/isIndex.ts @@ -1,9 +1,9 @@ const IS_UNSIGNED_INTEGER = /^(?:0|[1-9]\d*)$/; -export function isIndex(value: PropertyKey): boolean { +export function isIndex(value: PropertyKey, length = Number.MAX_SAFE_INTEGER): boolean { switch (typeof value) { case 'number': { - return Number.isInteger(value) && value >= 0 && value < Number.MAX_SAFE_INTEGER; + return Number.isInteger(value) && value >= 0 && value < length; } case 'symbol': { return false; diff --git a/src/compat/_internal/isPrototype.ts b/src/compat/_internal/isPrototype.ts index 2e76eb3b1..878d72b2e 100644 --- a/src/compat/_internal/isPrototype.ts +++ b/src/compat/_internal/isPrototype.ts @@ -1,5 +1,5 @@ export function isPrototype(value: {}) { - const constructor = value.constructor; + const constructor = value?.constructor; const prototype = typeof constructor === 'function' ? constructor.prototype : Object.prototype; return value === prototype; diff --git a/src/compat/_internal/primitives.ts b/src/compat/_internal/primitives.ts new file mode 100644 index 000000000..0e88e05df --- /dev/null +++ b/src/compat/_internal/primitives.ts @@ -0,0 +1 @@ +export const primitives = [null, undefined, false, true, 1, NaN, 'a']; diff --git a/src/compat/index.ts b/src/compat/index.ts index 15be574b7..772b77e98 100644 --- a/src/compat/index.ts +++ b/src/compat/index.ts @@ -105,6 +105,7 @@ export { fromPairs } from './object/fromPairs.ts'; export { get } from './object/get.ts'; export { has } from './object/has.ts'; export { invertBy } from './object/invertBy.ts'; +export { keys } from './object/keys.ts' export { mapKeys } from './object/mapKeys.ts'; export { mapValues } from './object/mapValues.ts'; export { merge } from './object/merge.ts'; diff --git a/src/compat/object/keys.spec.ts b/src/compat/object/keys.spec.ts new file mode 100644 index 000000000..581c2623d --- /dev/null +++ b/src/compat/object/keys.spec.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest'; +import { keys } from './keys'; +import { args } from '../_internal/args'; +import { arrayProto } from '../_internal/arrayProto'; +import { numberProto } from '../_internal/numberProto'; +import { objectProto } from '../_internal/objectProto'; +import { primitives } from '../_internal/primitives'; +import { strictArgs } from '../_internal/strictArgs'; +import { stringProto } from '../_internal/stringProto'; +import { stubArray } from '../_internal/stubArray'; +import { constant } from '../util/constant'; + +/** + * @see https://github.com/lodash/lodash/blob/afcd5bc1e8801867c31a17566e0e0edebb083d0e/test/keys-methods.spec.js#L1 + */ +describe('keys', () => { + it('should return the string keyed property names of `object`', () => { + const actual = keys({ a: 1, b: 1 }).sort(); + + expect(actual).toEqual(['a', 'b']); + }); + + it('should not include inherited string keyed properties', () => { + function Foo() { + // @ts-ignore + this.a = 1; + } + Foo.prototype.b = 2; + + const expected = ['a']; + // @ts-ignore + const actual = keys(new Foo()).sort(); + + expect(actual).toEqual(expected); + }); + + it('should treat sparse arrays as dense', () => { + const array = [1]; + array[2] = 3; + + const actual = keys(array).sort(); + + expect(actual).toEqual(['0', '1', '2']); + }); + + it('should return keys for custom properties on arrays', () => { + const array = [1]; + // @ts-ignore + array.a = 1; + + const actual = keys(array).sort(); + + expect(actual).toEqual(['0', 'a']); + }); + + it('should not include inherited string keyed properties of arrays', () => { + arrayProto.a = 1; + + const actual = keys([1]).sort(); + const expected = ['0']; + expect(actual).toEqual(expected); + + delete arrayProto.a; + }); + + it('should work with `arguments` objects', () => { + const values = [args, strictArgs]; + const expected = values.map(constant(['0', '1', '2'])); + + const actual = values.map(value => keys(value).sort()); + + expect(actual).toEqual(expected); + }); + + it('should return keys for custom properties on `arguments` objects', () => { + const values = [args, strictArgs]; + const expected = values.map(constant(['0', '1', '2', 'a'])); + + const actual = values.map(value => { + // @ts-ignore + value.a = 1; + const result = keys(value).sort(); + // @ts-ignore + delete value.a; + return result; + }); + + expect(actual).toEqual(expected); + }); + + it(`should not include inherited string keyed properties of \`arguments\` objects`, () => { + const values = [args, strictArgs]; + const expected = values.map(constant(['0', '1', '2'])); + + const actual = values.map(value => { + objectProto.a = 1; + const result = keys(value).sort(); + delete objectProto.a; + return result; + }); + + expect(actual).toEqual(expected); + }); + + it('should work with string objects', () => { + const actual = keys(Object('abc')).sort(); + + expect(actual).toEqual(['0', '1', '2']); + }); + + it('should return keys for custom properties on string objects', () => { + const object = Object('a'); + object.a = 1; + + const actual = keys(object).sort(); + + expect(actual).toEqual(['0', 'a']); + }); + + it(`should not include inherited string keyed properties of string objects`, () => { + stringProto.a = 1; + + const expected = ['0']; + const actual = keys(Object('a')).sort(); + + expect(actual).toEqual(expected); + + delete stringProto.a; + }); + + it('should work with array-like objects', () => { + const object = { 0: 'a', length: 1 }; + const actual = keys(object).sort(); + + expect(actual).toEqual(['0', 'length']); + }); + + it('should coerce primitives to objects (test in IE 9)', () => { + const expected = primitives.map(value => (typeof value === 'string' ? ['0'] : [])); + + const actual = primitives.map(keys); + expect(actual).toEqual(expected); + + // IE 9 doesn't box numbers in for-in loops. + numberProto.a = 1; + expect(keys(0)).toEqual([]); + delete numberProto.a; + }); + + it('skips the `constructor` property on prototype objects', () => { + function Foo() {} + Foo.prototype.a = 1; + + const expected = ['a']; + expect(keys(Foo.prototype)).toEqual(expected); + + Foo.prototype = { constructor: Foo, a: 1 }; + expect(keys(Foo.prototype)).toEqual(expected); + + const Fake = { prototype: {} }; + // @ts-ignore + Fake.prototype.constructor = Fake; + expect(keys(Fake.prototype)).toEqual(['constructor']); + }); + + it('should return an empty array when `object` is nullish', () => { + const values = [, null, undefined]; + const expected = values.map(stubArray); + + const actual = values.map((value, index) => { + objectProto.a = 1; + const result = index ? keys(value) : keys(); + delete objectProto.a; + return result; + }); + + expect(actual).toEqual(expected); + }); +}); diff --git a/src/compat/object/keys.ts b/src/compat/object/keys.ts new file mode 100644 index 000000000..50492141c --- /dev/null +++ b/src/compat/object/keys.ts @@ -0,0 +1,37 @@ +import { arrayLikeKeys } from '../_internal/arrayLikeKeys.ts'; +import { isPrototype } from '../_internal/isPrototype.ts'; +import { isArrayLike } from '../predicate/isArrayLike.ts'; + +/** + * Creates an array of the own enumerable property names of `object`. + * + * Non-object values are coerced to objects. + * + * @param {object} object The object to query. + * @returns {string[]} Returns the array of property names. + * @example + * + * function Foo() { + * this.a = 1; + * this.b = 2; + * } + * Foo.prototype.c = 3; + * keys(new Foo); // ['a', 'b'] (iteration order is not guaranteed) + * + * keys('hi'); // ['0', '1'] + * keys([1, 2, 3]); // ['0', '1', '2'] + * keys({ a: 1, b: 2 }); // ['a', 'b'] + */ +export function keys(object?: any): string[] { + if (isArrayLike(object)) { + return arrayLikeKeys(object, false); + } + + const result = Object.keys(Object(object)); + + if (!isPrototype(object)) { + return result; + } + + return result.filter(key => key !== 'constructor'); +}