diff --git a/.circleci/config.yml b/.circleci/config.yml index cbaa0c747..84481520c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -30,8 +30,11 @@ jobs: - checkout - setup - run: - name: Lint - command: yarn eslint -c .eslintrc.js $(git diff --name-only --diff-filter=ACMRUXB origin/main | grep -E "(.js$|.ts$|.tsx$)") + name: ESLint + command: yarn eslint -c .eslintrc.js $(git diff --name-only --diff-filter=ACMRUXB origin/main | grep -E "(.js$|.ts$)") + - run: + name: Prettier + command: modifiedFiles=$(git diff --name-only --diff-filter=ACMRUXB origin/main | grep -E "(.js$|.ts$)") && [ -n "$modifiedFiles" ] && yarn prettier -c $modifiedFiles || echo "No modified files." typecheck: docker: - image: cimg/node:20.12 diff --git a/docs/.vitepress/en.mts b/docs/.vitepress/en.mts index 274073af6..1d2b614b1 100644 --- a/docs/.vitepress/en.mts +++ b/docs/.vitepress/en.mts @@ -49,7 +49,7 @@ function sidebar(): DefaultTheme.Sidebar { text: 'Array Utilities', items: [ { text: 'chunk', link: '/reference/array/chunk' }, - { text: 'concat (compat)', link: '/reference/array/concat' }, + { text: 'concat (compat)', link: '/reference/compat/array/concat' }, { text: 'countBy', link: '/reference/array/countBy' }, { text: 'compact', link: '/reference/array/compact' }, { text: 'difference', link: '/reference/array/difference' }, @@ -138,6 +138,7 @@ function sidebar(): DefaultTheme.Sidebar { items: [ { text: 'clone', link: '/reference/object/clone' }, { text: 'invert', link: '/reference/object/invert' }, + { text: 'flattenObject', link: '/reference/object/flattenObject' }, { text: 'omit', link: '/reference/object/omit' }, { text: 'omitBy', link: '/reference/object/omitBy' }, { text: 'pick', link: '/reference/object/pick' }, @@ -150,6 +151,7 @@ function sidebar(): DefaultTheme.Sidebar { text: 'Predicates', items: [ { text: 'isEqual', link: '/reference/predicate/isEqual' }, + { text: 'isPlainObject', link: '/reference/predicate/isPlainObject' }, { text: 'isNil', link: '/reference/predicate/isNil' }, { text: 'isNotNil', link: '/reference/predicate/isNotNil' }, { text: 'isNull', link: '/reference/predicate/isNull' }, diff --git a/docs/.vitepress/ko.mts b/docs/.vitepress/ko.mts index bf8409fd9..f9e43955b 100644 --- a/docs/.vitepress/ko.mts +++ b/docs/.vitepress/ko.mts @@ -48,7 +48,7 @@ function sidebar(): DefaultTheme.Sidebar { text: '배열', items: [ { text: 'chunk', link: '/ko/reference/array/chunk' }, - { text: 'concat (호환성)', link: '/ko/reference/array/concat' }, + { text: 'concat (호환성)', link: '/ko/reference/compat/array/concat' }, { text: 'countBy', link: '/ko/reference/array/countBy' }, { text: 'compact', link: '/ko/reference/array/compact' }, { text: 'difference', link: '/ko/reference/array/difference' }, @@ -149,6 +149,7 @@ function sidebar(): DefaultTheme.Sidebar { items: [ { text: 'clone', link: '/ko/reference/object/clone' }, { text: 'invert', link: '/ko/reference/object/invert' }, + { text: 'flattenObject', link: '/ko/reference/object/flattenObject' }, { text: 'omit', link: '/ko/reference/object/omit' }, { text: 'omitBy', link: '/ko/reference/object/omitBy' }, { text: 'pick', link: '/ko/reference/object/pick' }, @@ -161,6 +162,7 @@ function sidebar(): DefaultTheme.Sidebar { text: '타입 가드', items: [ { text: 'isEqual', link: '/ko/reference/predicate/isEqual' }, + { text: 'isPlainObject', link: '/ko/reference/predicate/isPlainObject' }, { text: 'isNil', link: '/ko/reference/predicate/isNil' }, { text: 'isNotNil', link: '/ko/reference/predicate/isNotNil' }, { text: 'isNull', link: '/ko/reference/predicate/isNull' }, diff --git a/docs/.vitepress/zh_hans.mts b/docs/.vitepress/zh_hans.mts index df0cb07d3..d6fcf5e60 100644 --- a/docs/.vitepress/zh_hans.mts +++ b/docs/.vitepress/zh_hans.mts @@ -48,7 +48,7 @@ function sidebar(): DefaultTheme.Sidebar { text: '数组工具', items: [ { text: 'chunk', link: '/zh_hans/reference/array/chunk' }, - { text: 'concat (兼容性)', link: '/zh_hans/reference/array/concat' }, + { text: 'concat (兼容性)', link: '/zh_hans/reference/compat/array/concat' }, { text: 'countBy', link: '/zh_hans/reference/array/countBy' }, { text: 'compact', link: '/zh_hans/reference/array/compact' }, { text: 'difference', link: '/zh_hans/reference/array/difference' }, @@ -134,6 +134,7 @@ function sidebar(): DefaultTheme.Sidebar { items: [ { text: 'clone', link: '/zh_hans/reference/object/clone' }, { text: 'invert', link: '/zh_hans/reference/object/invert' }, + { text: 'flattenObject', link: '/zh_hans/reference/object/flattenObject' }, { text: 'omit', link: '/zh_hans/reference/object/omit' }, { text: 'omitBy', link: '/zh_hans/reference/object/omitBy' }, { text: 'pick', link: '/zh_hans/reference/object/pick' }, @@ -146,6 +147,7 @@ function sidebar(): DefaultTheme.Sidebar { text: '谓词', items: [ { text: 'isEqual', link: '/zh_hans/reference/predicate/isEqual' }, + { text: 'isPlainObject', link: '/zh_hans/reference/predicate/isPlainObject' }, { text: 'isNil', link: '/zh_hans/reference/predicate/isNil' }, { text: 'isNotNil', link: '/zh_hans/reference/predicate/isNotNil' }, { text: 'isNull', link: '/zh_hans/reference/predicate/isNull' }, diff --git a/docs/compatibility.md b/docs/compatibility.md index 41cd917c8..3a2439d84 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -31,6 +31,7 @@ However, the following are out of scope for `es-toolkit/compat`: - Implicit type conversions, such as converting an empty string to zero or false. - Functions that have specialized implementations for specific types of arrays, like [sortedUniq](https://lodash.com/docs/4.17.15#sortedUniq). +- Handling cases where internal object prototypes, like `Array.prototype`, have been modified. - Method chaining support through "Seq" methods. ## Implementation Status @@ -66,7 +67,7 @@ Even if a feature is marked "in review," it might already be under review to ens | [flattenDeep](https://lodash.com/docs/4.17.15#flattenDeep) | 📝 | | [flattenDepth](https://lodash.com/docs/4.17.15#flattenDepth) | 📝 | | [fromPairs](https://lodash.com/docs/4.17.15#fromPairs) | ❌ | -| [head](https://lodash.com/docs/4.17.15#head) | 📝 | +| [head](https://lodash.com/docs/4.17.15#head) | ✅ | | [indexOf](https://lodash.com/docs/4.17.15#indexOf) | ❌ | | [initial](https://lodash.com/docs/4.17.15#initial) | 📝 | | [intersection](https://lodash.com/docs/4.17.15#intersection) | 📝 | @@ -92,7 +93,7 @@ Even if a feature is marked "in review," it might already be under review to ens | [sortedLastIndexOf](https://lodash.com/docs/4.17.15#sortedLastIndexOf) | No support | | [sortedUniq](https://lodash.com/docs/4.17.15#sortedUniq) | No support | | [sortedUniqBy](https://lodash.com/docs/4.17.15#sortedUniqBy) | No support | -| [tail](https://lodash.com/docs/4.17.15#tail) | 📝 | +| [tail](https://lodash.com/docs/4.17.15#tail) | ✅ | | [take](https://lodash.com/docs/4.17.15#take) | ✅ | | [takeRight](https://lodash.com/docs/4.17.15#takeRight) | 📝 | | [takeRightWhile](https://lodash.com/docs/4.17.15#takeRightWhile) | 📝 | @@ -219,7 +220,7 @@ Even if a feature is marked "in review," it might already be under review to ens | [isNumber](https://lodash.com/docs/4.17.15#isNumber) | ❌ | | [isObject](https://lodash.com/docs/4.17.15#isObject) | ❌ | | [isObjectLike](https://lodash.com/docs/4.17.15#isObjectLike) | ❌ | -| [isPlainObject](https://lodash.com/docs/4.17.15#isPlainObject) | ❌ | +| [isPlainObject](https://lodash.com/docs/4.17.15#isPlainObject) | ✅ | | [isRegExp](https://lodash.com/docs/4.17.15#isRegExp) | ❌ | | [isSafeInteger](https://lodash.com/docs/4.17.15#isSafeInteger) | ❌ | | [isSet](https://lodash.com/docs/4.17.15#isSet) | ❌ | diff --git a/docs/ko/compatibility.md b/docs/ko/compatibility.md index 76dd7106f..685db94c0 100644 --- a/docs/ko/compatibility.md +++ b/docs/ko/compatibility.md @@ -32,6 +32,7 @@ chunk([1, 2, 3, 4], 0); - 암시적 타입 변환: 빈 문자열을 0 또는 false로 변환하는 것과 같은 동작 - 어떤 경우에 특화된 구현: [sortedUniq](https://lodash.com/docs/4.17.15#sortedUniq)와 같이 정렬된 배열만 받는 함수 +- JavaScript 내장 객체의 프로토타입이 바뀐 경우에 대응하는 코드 - 메서드 체이닝: `_(arr).map(...).filter(...)`와 같은 메서드 체이닝 ## 구현 상태 diff --git a/docs/ko/reference/object/flattenObject.md b/docs/ko/reference/object/flattenObject.md new file mode 100644 index 000000000..ad0539948 --- /dev/null +++ b/docs/ko/reference/object/flattenObject.md @@ -0,0 +1,42 @@ +# flattenObject + +중첩된 객체를 단순한 객체로 평탄화해요. + +- `Array`는 평탄화돼요. +- 순수 객체가 아닌 `Buffer`나 `TypedArray`는 평탄화되지 않아요. + +## 인터페이스 + +```typescript +function flattenObject(object: object): Record; +``` + +### 파라미터 + +- `object` (`object`): 평탄화할 객체. + +### 반환 값 + +(`T`): 평탄화된 객체. + +## 예시 + +```typescript +const nestedObject = { + a: { + b: { + c: 1 + } + }, + d: [2, 3] +}; + +const flattened = flattenObject(nestedObject); +console.log(flattened); +// Output: +// { +// 'a.b.c': 1, +// 'd.0': 2, +// 'd.1': 3 +// } +``` diff --git a/docs/ko/reference/predicate/isPlainObject.md b/docs/ko/reference/predicate/isPlainObject.md new file mode 100644 index 000000000..2fd728f10 --- /dev/null +++ b/docs/ko/reference/predicate/isPlainObject.md @@ -0,0 +1,27 @@ +# isPlainObject + +주어진 값이 순수 객체(Plain object)인지 확인해요. + +## 인터페이스 + +```typescript +function isPlainObject(object: object): boolean; +``` + +### 파라미터 + +- `object` (`object`): 검사할 값. + +### Returns + +(`boolean`): 값이 순수 객체이면 true. + +## Examples + +```typescript +console.log(isPlainObject({})); // true +console.log(isPlainObject([])); // false +console.log(isPlainObject(null)); // false +console.log(isPlainObject(Object.create(null))); // true +console.log(Buffer.from('hello, world')); // false +``` diff --git a/docs/reference/object/flattenObject.md b/docs/reference/object/flattenObject.md new file mode 100644 index 000000000..24b968d64 --- /dev/null +++ b/docs/reference/object/flattenObject.md @@ -0,0 +1,42 @@ +# flattenObject + +Flattens a nested object into a single-level object with dot-separated keys. + +- `Array`s are flattened. +- Non-plain objects, like `Buffer`s or `TypedArray`s, are not flattened. + +## Signature + +```typescript +function flattenObject(object: object): Record; +``` + +### Parameters + +- `object` (`object`): The object to flatten. + +### Returns + +(`T`): The flattened object. + +## Examples + +```typescript +const nestedObject = { + a: { + b: { + c: 1 + } + }, + d: [2, 3] +}; + +const flattened = flattenObject(nestedObject); +console.log(flattened); +// Output: +// { +// 'a.b.c': 1, +// 'd.0': 2, +// 'd.1': 3 +// } +``` diff --git a/docs/reference/predicate/isPlainObject.md b/docs/reference/predicate/isPlainObject.md new file mode 100644 index 000000000..7abda020c --- /dev/null +++ b/docs/reference/predicate/isPlainObject.md @@ -0,0 +1,27 @@ +# isPlainObject + +Checks if a given value is a plain object. + +## Signature + +```typescript +function isPlainObject(object: object): boolean; +``` + +### Parameters + +- `object` (`object`): The value to check. + +### Returns + +(`boolean`): True if the value is a plain object, otherwise false. + +## Examples + +```typescript +console.log(isPlainObject({})); // true +console.log(isPlainObject([])); // false +console.log(isPlainObject(null)); // false +console.log(isPlainObject(Object.create(null))); // true +console.log(Buffer.from('hello, world')); // false +``` diff --git a/docs/zh_hans/compatibility.md b/docs/zh_hans/compatibility.md index 50d7b0b3d..930e0c120 100644 --- a/docs/zh_hans/compatibility.md +++ b/docs/zh_hans/compatibility.md @@ -31,6 +31,7 @@ chunk([1, 2, 3, 4], 0); - 隐式类型转换,例如将空字符串转换为零或假。 - 对特定类型数组有专门实现的函数,比如 [sortedUniq](https://lodash.com/docs/4.17.15#sortedUniq)。 +- 处理内部对象原型(例如 `Array.prototype`)被修改的情况。 - 通过 "Seq" 方法支持方法链。 ## 实现状态 diff --git a/docs/zh_hans/reference/array/chunk.md b/docs/zh_hans/reference/array/chunk.md index 0f48a04ca..1d2e2925c 100644 --- a/docs/zh_hans/reference/array/chunk.md +++ b/docs/zh_hans/reference/array/chunk.md @@ -54,7 +54,7 @@ chunk([1, 2, 3], 0); // Returns [] ## 性能对比 -| | [包大小](../../bundle-size.md) | [런타임 성능](../../performance.md) | +| | [包大小](../../bundle-size.md) | [性能](../../performance.md) | | ----------------- | ------------------------------ | ----------------------------------- | | es-toolkit | 238 字节 (小 92.4%) | 9,338,821 次 (慢 11%) | | es-toolkit/compat | 307 字节 (小 90.2%) | 9,892,157 次 (慢 5%) | diff --git a/docs/zh_hans/reference/object/flattenObject.md b/docs/zh_hans/reference/object/flattenObject.md new file mode 100644 index 000000000..092674fc8 --- /dev/null +++ b/docs/zh_hans/reference/object/flattenObject.md @@ -0,0 +1,42 @@ +# flattenObject + +将嵌套对象扁平化为具有点分隔键的单级对象。 + +- `Array` 会被扁平化。 +- 非普通对象,如 `Buffer` 或 `TypedArray`,不会被扁平化。 + +## 签名 + +```typescript +function flattenObject(object: object): Record; +``` + +### 参数 + +- `object` (`object`): 要扁平化的对象。 + +### 返回值 + +(`T`): 扁平化后的对象。 + +## 示例 + +```typescript +const nestedObject = { + a: { + b: { + c: 1 + } + }, + d: [2, 3] +}; + +const flattened = flattenObject(nestedObject); +console.log(flattened); +// 输出: +// { +// 'a.b.c': 1, +// 'd.0': 2, +// 'd.1': 3 +// } +``` \ No newline at end of file diff --git a/docs/zh_hans/reference/predicate/isPlainObject.md b/docs/zh_hans/reference/predicate/isPlainObject.md new file mode 100644 index 000000000..f9365673d --- /dev/null +++ b/docs/zh_hans/reference/predicate/isPlainObject.md @@ -0,0 +1,27 @@ +# isPlainObject + +检查给定值是否是一个普通对象。 + +## 签名 + +```typescript +function isPlainObject(object: object): boolean; +``` + +### 参数 + +- `object` (`object`): 要检查的值。 + +### 返回值 + +(`boolean`): 如果该值是普通对象,则返回 `true`,否则返回 `false`。 + +## 示例 + +```typescript +console.log(isPlainObject({})); // true +console.log(isPlainObject([])); // false +console.log(isPlainObject(null)); // false +console.log(isPlainObject(Object.create(null))); // true +console.log(Buffer.from('hello, world')); // false +``` diff --git a/src/array/tail.ts b/src/array/tail.ts index e4ab29084..c21cfac6b 100644 --- a/src/array/tail.ts +++ b/src/array/tail.ts @@ -60,6 +60,8 @@ export function tail(arr: readonly [T, ...U[]]): U[]; * const result3 = tail(arr3); * // result3 will be [] */ +export function tail(arr: readonly T[]): T[]; + export function tail(arr: readonly T[]): T[] { const len = arr.length; if (len <= 1) { diff --git a/src/compat/array/head.spec.ts b/src/compat/array/head.spec.ts new file mode 100644 index 000000000..cdbb34f50 --- /dev/null +++ b/src/compat/array/head.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { head, first } from '../index'; + +/** + * @see https://github.com/lodash/lodash/blob/6a2cc1dfcf7634fea70d1bc5bd22db453df67b42/test/head.spec.js#L1 + */ +describe('head', () => { + const array = [1, 2, 3, 4]; + + it('should return the first element', () => { + expect(head(array)).toBe(1); + }); + + it('should work as an iteratee for methods like `map`', () => { + const array = [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ]; + const actual = array.map(head); + + expect(actual).toEqual([1, 4, 7]); + }); + + it('should be aliased', () => { + expect(first).toBe(head); + }); +}); diff --git a/src/compat/array/tail.spec.ts b/src/compat/array/tail.spec.ts new file mode 100644 index 000000000..913d41b42 --- /dev/null +++ b/src/compat/array/tail.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { tail } from '../index'; + +/** + * @see https://github.com/lodash/lodash/blob/6a2cc1dfcf7634fea70d1bc5bd22db453df67b42/test/tail.spec.js#L1 + */ +describe('tail', () => { + const array = [1, 2, 3]; + + it('should exclude the first element', () => { + expect(tail(array)).toEqual([2, 3]); + }); + + it('should return an empty when querying empty arrays', () => { + expect(tail([])).toEqual([]); + }); + + it('should work as an iteratee for methods like `map`', () => { + const array = [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ]; + const actual = array.map(tail); + + expect(actual).toEqual([ + [2, 3], + [5, 6], + [8, 9], + ]); + }); +}); diff --git a/src/compat/index.ts b/src/compat/index.ts index a89562633..b3a558455 100644 --- a/src/compat/index.ts +++ b/src/compat/index.ts @@ -28,10 +28,13 @@ export { chunk } from './array/chunk.ts'; export { concat } from './array/concat.ts'; export { difference } from './array/difference.ts'; export { zipObjectDeep } from './array/zipObjectDeep.ts'; +export { head as first } from '../index.ts'; export { get } from './object/get.ts'; export { set } from './object/set.ts'; +export { isPlainObject } from './predicate/isPlainObject.ts'; + export { startsWith } from './string/startsWith.ts'; export { endsWith } from './string/endsWith.ts'; diff --git a/src/compat/predicate/isPlainObject.spec.ts b/src/compat/predicate/isPlainObject.spec.ts new file mode 100644 index 000000000..d16075bf8 --- /dev/null +++ b/src/compat/predicate/isPlainObject.spec.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { isPlainObject } from "./isPlainObject"; +import { falsey } from "../_internal/falsey"; + +describe('isPlainObject', () => { + it('should detect plain objects', () => { + class Foo { + a: number; + b: number; + + constructor(b: number) { + this.a = 1; + this.b = b + } + } + + expect(isPlainObject({})).toBe(true); + expect(isPlainObject({ a: 1 })).toBe(true); + expect(isPlainObject({ constructor: Foo })).toBe(true); + expect(isPlainObject([1, 2, 3])).toBe(false); + expect(isPlainObject(new Foo(1))).toBe(false); + }); + + it('should return `true` for objects with a `[[Prototype]]` of `null`', () => { + const object = Object.create(null); + expect(isPlainObject(object)).toBe(true); + + object.constructor = Object.prototype.constructor; + expect(isPlainObject(object)).toBe(true); + }); + + it('should return `true` for objects with a `valueOf` property', () => { + expect(isPlainObject({ valueOf: 0 })).toBe(true); + }); + + it('should return `true` for objects with a writable `Symbol.toStringTag` property', () => { + if (Symbol && Symbol.toStringTag) { + const object = {}; + // @ts-ignore + object[Symbol.toStringTag] = 'X'; + + expect(isPlainObject(object)).toEqual(true); + } + }); + + it('should return `false` for objects with a custom `[[Prototype]]`', () => { + const object = Object.create({ a: 1 }); + expect(isPlainObject(object)).toBe(false); + }); + + it('should return `false` for non-Object objects', function () { + expect(isPlainObject(arguments)).toBe(false); + expect(isPlainObject(Error)).toBe(false); + expect(isPlainObject(Math)).toBe(false); + }); + + it('should return `false` for non-objects', () => { + const expected = falsey.map(() => false); + + const actual = falsey.map((value, index) => + index ? isPlainObject(value) : isPlainObject(), + ); + + expect(actual).toEqual(expected); + + expect(isPlainObject(true)).toBe(false); + expect(isPlainObject('a')).toBe(false); + expect(isPlainObject(Symbol('a'))).toBe(false); + }); + + it('should return `false` for objects with a read-only `Symbol.toStringTag` property', () => { + if (Symbol && Symbol.toStringTag) { + const object = {}; + Object.defineProperty(object, Symbol.toStringTag, { + configurable: true, + enumerable: false, + writable: false, + value: 'X', + }); + + expect(isPlainObject(object)).toEqual(false); + } + }); + + it('should not mutate `value`', () => { + if (Symbol && Symbol.toStringTag) { + const proto = {}; + // @ts-ignore + proto[Symbol.toStringTag] = undefined; + const object = Object.create(proto); + + expect(isPlainObject(object)).toBe(false); + expect(object.hasOwnProperty(Symbol.toStringTag)).toBe(false); + } + }); +}); diff --git a/src/compat/predicate/isPlainObject.ts b/src/compat/predicate/isPlainObject.ts new file mode 100644 index 000000000..cb4a7c1be --- /dev/null +++ b/src/compat/predicate/isPlainObject.ts @@ -0,0 +1,60 @@ +/** + * Checks if a given value is a plain object. + * + * A plain object is an object created by the `{}` literal, `new Object()`, or + * `Object.create(null)`. + * + * This function also handles objects with custom + * `Symbol.toStringTag` properties. + * + * `Symbol.toStringTag` is a built-in symbol that a constructor can use to customize the + * default string description of objects. + * + * @param {unknown} [object] - The value to check. + * @returns {boolean} - True if the value is a plain object, otherwise false. + * + * @example + * console.log(isPlainObject({})); // true + * console.log(isPlainObject([])); // false + * console.log(isPlainObject(null)); // false + * console.log(isPlainObject(Object.create(null))); // true + * console.log(isPlainObject(new Map())); // false + */ +export function isPlainObject(object?: unknown): boolean { + if (typeof object !== 'object') { + return false; + } + + if (object == null) { + return false; + } + + if (Object.getPrototypeOf(object) === null) { + return true; + } + + if (object.toString() !== '[object Object]') { + // @ts-ignore + const tag = object[Symbol.toStringTag]; + + if (tag == null) { + return false; + } + + const isTagReadonly = !Object.getOwnPropertyDescriptor(object, Symbol.toStringTag)?.writable; + + if (isTagReadonly) { + return false; + } + + return object.toString() === `[object ${tag}]`; + } + + let proto = object; + + while (Object.getPrototypeOf(proto) !== null) { + proto = Object.getPrototypeOf(proto); + } + + return Object.getPrototypeOf(object) === proto; +} \ No newline at end of file diff --git a/src/object/flattenObject.spec.ts b/src/object/flattenObject.spec.ts new file mode 100644 index 000000000..ca92fa32a --- /dev/null +++ b/src/object/flattenObject.spec.ts @@ -0,0 +1,140 @@ +import { describe, expect, it, test } from "vitest" +import { flattenObject } from "./flattenObject"; + +describe('flattenObject', function () { + it('flattens primitive values correctly', () => { + const result1 = flattenObject({ + a: { + b: 'yay' + } + }); + + expect(result1).toEqual({ + 'a.b': 'yay' + }) + + const result2 = flattenObject({ + a: { + b: { + string: 'hello world', + number: 1234.5678, + boolean: true, + null: null, + undefined: undefined, + date: new Date(), + } + } + }) + + expect(result2).toEqual({ + 'a.b.string': 'hello world', + 'a.b.number': 1234.5678, + 'a.b.boolean': true, + 'a.b.null': null, + 'a.b.undefined': undefined, + 'a.b.date': new Date(), + }) + }) + + it('flattens multiple keys', () => { + const result = flattenObject({ + a: { + b: { + c: 1, + }, + d: { + e: { + f: { + g: new Date() + } + } + } + }, + h: { + i: 'hi' + } + }); + + expect(result).toEqual({ + 'a.b.c': 1, + 'a.d.e.f.g': new Date(), + 'h.i': 'hi' + }) + }) + + it('handles empty objects correctly', () => { + const result = flattenObject({ + a: { + b: {} + } + }); + + expect(result).toEqual({ + 'a.b': {}, + }) + }) + + it('handles `Buffer`s correctly', () => { + const result = flattenObject({ + a: { + b: Buffer.from('test') + } + }); + + expect(result).toEqual({ + 'a.b': Buffer.from('test') + }); + }) + + it('handles `TypedArray`s correctly', () => { + const result = flattenObject({ + a: { + b: new Uint8Array([1, 2, 3, 4]) + } + }); + + expect(result).toEqual({ + 'a.b': new Uint8Array([1, 2, 3, 4]) + }); + }) + + it('handles numeric keys', () => { + const result = flattenObject({ + '01': { + '02': { + '03': 1, + }, + } + }); + + expect(result).toEqual({ + '01.02.03': 1, + }); + }) + + it('handles mixed keys', () => { + const result = flattenObject({ + 'a1': { + 'b2': { + 'c3': 1, + }, + } + }); + + expect(result).toEqual({ + 'a1.b2.c3': 1 + }); + }) + + it('handles arrays', () => { + const result = flattenObject({ + a: [1, 2, 3] + }); + + expect(result).toEqual({ + 'a.0': 1, + 'a.1': 2, + 'a.2': 3, + }); + }) +}) \ No newline at end of file diff --git a/src/object/flattenObject.ts b/src/object/flattenObject.ts new file mode 100644 index 000000000..ad2409409 --- /dev/null +++ b/src/object/flattenObject.ts @@ -0,0 +1,58 @@ +import { isPlainObject } from "../predicate/isPlainObject.ts"; + +/** + * Flattens a nested object into a single level object with dot-separated keys. + * + * @param {object} object - The object to flatten. + * @returns {Record} - The flattened object. + * + * @example + * const nestedObject = { + * a: { + * b: { + * c: 1 + * } + * }, + * d: [2, 3] + * }; + * + * const flattened = flattenObject(nestedObject); + * console.log(flattened); + * // Output: + * // { + * // 'a.b.c': 1, + * // 'd.0': 2, + * // 'd.1': 3 + * // } + */ +export function flattenObject(object: object): Record { + return flattenObjectImpl(object); +} + +function flattenObjectImpl(object: object, prefix = ''): Record { + const result: Record = {}; + const keys = Object.keys(object); + + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const value = (object as any)[key]; + + const prefixedKey = prefix ? `${prefix}.${key}` : key; + + if (isPlainObject(value) && Object.keys(value).length > 0) { + Object.assign(result, flattenObjectImpl(value, prefixedKey)); + continue; + } + + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index++) { + result[`${prefixedKey}.${index}`] = value[index]; + } + continue; + } + + result[prefixedKey] = value; + } + + return result; +} \ No newline at end of file diff --git a/src/object/index.ts b/src/object/index.ts index e7c0a7f24..ba6bae427 100644 --- a/src/object/index.ts +++ b/src/object/index.ts @@ -4,3 +4,4 @@ export { pick } from './pick.ts'; export { pickBy } from './pickBy.ts'; export { invert } from './invert.ts'; export { clone } from './clone.ts'; +export { flattenObject } from './flattenObject.ts'; \ No newline at end of file diff --git a/src/predicate/index.ts b/src/predicate/index.ts index 76f405d9d..9b8188f66 100644 --- a/src/predicate/index.ts +++ b/src/predicate/index.ts @@ -3,3 +3,4 @@ export { isNil } from './isNil.ts'; export { isNotNil } from './isNotNil.ts'; export { isNull } from './isNull.ts'; export { isUndefined } from './isUndefined.ts'; +export { isPlainObject } from './isPlainObject.ts'; \ No newline at end of file diff --git a/src/predicate/isPlainObject.spec.ts b/src/predicate/isPlainObject.spec.ts new file mode 100644 index 000000000..001be4bb8 --- /dev/null +++ b/src/predicate/isPlainObject.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { isPlainObject } from "./isPlainObject"; + +describe('isPlainObject', () => { + it('returns true for plain objects', () => { + expect(isPlainObject({})).toBe(true); + expect(isPlainObject(Object.create(null))).toBe(true); + expect(isPlainObject(new Object())).toBe(true); + }); + + it('returns false for non-plain objects', () => { + expect(isPlainObject([])).toBe(false); + expect(isPlainObject(new Date())).toBe(false); + expect(isPlainObject(new Map())).toBe(false); + expect(isPlainObject(Buffer.from('123123'))).toBe(false); + expect(isPlainObject(new Uint8Array([1, 2, 3]))).toBe(false); + }); +}); \ No newline at end of file diff --git a/src/predicate/isPlainObject.ts b/src/predicate/isPlainObject.ts new file mode 100644 index 000000000..059f48cd2 --- /dev/null +++ b/src/predicate/isPlainObject.ts @@ -0,0 +1,38 @@ +/** + * Checks if a given value is a plain object. + * + * @param {object} object - The value to check. + * @returns {boolean} - True if the value is a plain object, otherwise false. + * + * @example + * console.log(isPlainObject({})); // true + * console.log(isPlainObject([])); // false + * console.log(isPlainObject(null)); // false + * console.log(isPlainObject(Object.create(null))); // true + * console.log(Buffer.from('hello, world')); // false + */ +export function isPlainObject(object: object): boolean { + if (typeof object !== 'object') { + return false; + } + + if (object == null) { + return false; + } + + if (Object.getPrototypeOf(object) === null) { + return true; + } + + if (object.toString() !== '[object Object]') { + return false; + } + + let proto = object; + + while (Object.getPrototypeOf(proto) !== null) { + proto = Object.getPrototypeOf(proto); + } + + return Object.getPrototypeOf(object) === proto; +} \ No newline at end of file