diff --git a/README.md b/README.md index 8f0ce96..d6b10dc 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,40 @@ const omitted = $omit(user, ["email"]); // omitted = { id: 1, name: 'John Doe' } console.log(omitted.email); // Output: undefined ``` +```ts +import { $deepMerge } from "@uchihori/utilities/object"; + +const target = { + a: 1, + b: { + c: "hello", + d: [1, 2], + }, +}; + +const source = { + b: { + d: [3, 4], + e: true, + }, + f: "world", +}; + +const merged = $deepMerge(target, source); +console.log(merged); +/* Output: +{ + a: 1, + b: { + c: "hello", + d: [1, 2, 3, 4], + e: true + }, + f: "world" +} +*/ +``` + ## array ```ts @@ -367,3 +401,36 @@ import type { FromPairs } from "./types.ts"; type ObjectFromPairs = FromPairs<[["a", 1], ["b", 2]]>; // { a: 1, b: 2 } ``` + +```ts +import type { DeepMerge } from "./types.ts"; + +type A = { + a: number; + b: { + c: string; + d: number[]; + }; +}; + +type B = { + b: { + d: boolean[]; + e: boolean; + }; + f: string; +}; + +type Merged = DeepMerge; +/* Result: +{ + a: number; + b: { + c: string; + d: (number | boolean)[]; + e: boolean; + }; + f: string; +} +*/ +``` diff --git a/deno.json b/deno.json index 8c0e5fd..054842b 100644 --- a/deno.json +++ b/deno.json @@ -15,5 +15,5 @@ "@std/assert": "jsr:@std/assert@^0.226.0", "@std/testing": "jsr:@std/testing@^0.225.3" }, - "version": "0.5.0" + "version": "0.6.0" } diff --git a/object.test.ts b/object.test.ts index 40dffcc..e268607 100644 --- a/object.test.ts +++ b/object.test.ts @@ -1,6 +1,6 @@ import { assertEquals } from "@std/assert/assert-equals"; import { assertThrows } from "@std/assert/assert-throws"; -import { $omit, $pick } from "./object.ts"; +import { $deepMerge, $omit, $pick } from "./object.ts"; Deno.test("$pick", () => { const obj = { a: "1", b: 2 }; @@ -172,3 +172,69 @@ Deno.test("$omit with invalid arguments", () => { "expected an array for a second argument", ); }); + +Deno.test("$deepMerge", () => { + // 基本的なオブジェクトのマージ + const obj1 = { a: 1, b: 2 }; + const obj2 = { b: 3, c: 4 }; + const merged1 = $deepMerge(obj1, obj2); + assertEquals(merged1, { a: 1, b: 3, c: 4 }); + + // ネストされたオブジェクトのマージ + const nested1 = { a: 1, b: { c: 2, d: 3 } }; + const nested2 = { b: { d: 4, e: 5 }, f: 6 }; + const mergedNested = $deepMerge(nested1, nested2); + assertEquals(mergedNested, { a: 1, b: { c: 2, d: 4, e: 5 }, f: 6 }); + + // 配列のマージ + const withArray1 = { a: 1, b: [1, 2, 3] }; + const withArray2 = { b: [4, 5], c: 3 }; + const mergedArray = $deepMerge(withArray1, withArray2); + assertEquals(mergedArray, { a: 1, b: [1, 2, 3, 4, 5], c: 3 }); + + // ネストされた複雑なオブジェクトのマージ + const complex1 = { a: 1, b: { c: 2, d: { e: 3, f: 4 } } }; + const complex2 = { b: { d: { f: 5, g: 6 }, h: 7 } }; + const mergedComplex = $deepMerge(complex1, complex2); + assertEquals(mergedComplex, { + a: 1, + b: { c: 2, d: { e: 3, f: 5, g: 6 }, h: 7 }, + }); + + // 配列を含むネストされたオブジェクトのマージ + const arrayNested1 = { a: { b: [1, 2], c: 3 } }; + const arrayNested2 = { a: { b: [4, 5], d: 6 } }; + const mergedArrayNested = $deepMerge(arrayNested1, arrayNested2); + assertEquals(mergedArrayNested, { a: { b: [1, 2, 4, 5], c: 3, d: 6 } }); +}); + +Deno.test("$deepMerge with invalid arguments", () => { + // 非オブジェクト引数に対するエラーハンドリング + assertThrows( + // deno-lint-ignore ban-ts-comment + // @ts-ignore + () => $deepMerge("not an object", {}), + "expected a non-null object for the first argument", + ); + + assertThrows( + // deno-lint-ignore ban-ts-comment + // @ts-ignore + () => $deepMerge({}, "not an object"), + "expected a non-null object for the second argument", + ); + + assertThrows( + // deno-lint-ignore ban-ts-comment + // @ts-ignore + () => $deepMerge(null, {}), + "expected a non-null object for the first argument", + ); + + assertThrows( + // deno-lint-ignore ban-ts-comment + // @ts-ignore + () => $deepMerge({}, null), + "expected a non-null object for the second argument", + ); +}); diff --git a/object.ts b/object.ts index be990f5..f47aaa2 100644 --- a/object.ts +++ b/object.ts @@ -59,3 +59,77 @@ export const $omit = ( } return result; }; + +import type { DeepMerge } from "./types.ts"; + +/** + * Deeply merges two or more objects. + * + * @template T - The type of the first object. + * @template U - The type of the second object. + * @param {T} target - The first object to merge. + * @param {U} source - The second object to merge. + * @returns {DeepMerge} A new object with all properties deeply merged. + * @throws Will throw an error if any argument is not a non-null object. + */ +export const $deepMerge = ( + target: T, + source: U, +): DeepMerge => { + // 基本的な検証 + if (typeof target !== "object" || target === null) { + throw new Error("expected a non-null object for the first argument"); + } + if (typeof source !== "object" || source === null) { + throw new Error("expected a non-null object for the second argument"); + } + + // 2つのオブジェクトをマージする内部関数 + const merge = ( + target: A, + source: B, + ): DeepMerge => { + // 結果オブジェクトを作成 + const result = { ...target } as Record; + + // ソースオブジェクトのプロパティをマージ + for (const key in source) { + if (Object.prototype.hasOwnProperty.call(source, key)) { + const sourceValue = source[key as keyof B]; + const targetValue = key in target + ? (target as Record)[key] + : undefined; + + if ( + targetValue !== null && + targetValue !== undefined && + sourceValue !== null && + sourceValue !== undefined && + typeof targetValue === "object" && + typeof sourceValue === "object" + ) { + if (Array.isArray(targetValue) && Array.isArray(sourceValue)) { + // 両方が配列の場合はマージ(連結) + result[key] = [...targetValue, ...sourceValue] as unknown; + } else if ( + !Array.isArray(targetValue) && + !Array.isArray(sourceValue) + ) { + // 両方がオブジェクト(配列でない)の場合は再帰的にマージ + result[key] = merge(targetValue as object, sourceValue as object); + } else { + // 一方が配列で一方がオブジェクトの場合はソースの値で上書き + result[key] = sourceValue; + } + } else { + // それ以外の場合はソースの値で上書き + result[key] = sourceValue; + } + } + } + + return result as DeepMerge; + }; + + return merge(target, source); +}; diff --git a/type.test.ts b/type.test.ts index 98ca8e3..5f69510 100644 --- a/type.test.ts +++ b/type.test.ts @@ -1,5 +1,6 @@ import type { AssertTrue, IsExact } from "@std/testing/types"; import type { + DeepMerge, FillArray, FromPairs, IsTuple, @@ -139,3 +140,50 @@ Deno.test("RemoveItems", () => { IsExact, (number | string)[]> >; }); + +Deno.test("DeepMerge", () => { + // 基本的なオブジェクトのマージ + type A = { a: number; b: string }; + type B = { b: boolean; c: number }; + type ExpectedAB = { a: number; b: boolean; c: number }; + type _1 = AssertTrue, ExpectedAB>>; + + // ネストされたオブジェクトのマージ + type C = { a: number; b: { c: string; d: number } }; + type D = { b: { d: boolean; e: string }; f: number }; + type ExpectedCD = { + a: number; + b: { c: string; d: boolean; e: string }; + f: number; + }; + type _2 = AssertTrue, ExpectedCD>>; + + // 配列のマージ + type E = { a: number; b: string[]; c: { d: number[] } }; + type F = { b: boolean[]; c: { d: string[] } }; + type ExpectedEF = { + a: number; + b: Array; + c: { d: Array }; + }; + type _3 = AssertTrue, ExpectedEF>>; + + // プリミティブ値の上書き + type G = { a: number; b: string }; + type H = { a: string }; + type ExpectedGH = { a: string; b: string }; + type _4 = AssertTrue, ExpectedGH>>; + + // 複雑なネストされたオブジェクトのマージ + type I = { a: { b: { c: number; d: string }; e: boolean }; f: number[] }; + type J = { a: { b: { d: number; g: boolean }; h: string }; f: string[] }; + type ExpectedIJ = { + a: { + b: { c: number; d: number; g: boolean }; + e: boolean; + h: string; + }; + f: Array; + }; + type _5 = AssertTrue, ExpectedIJ>>; +}); diff --git a/types.ts b/types.ts index 32f2171..4173881 100644 --- a/types.ts +++ b/types.ts @@ -216,3 +216,40 @@ export type RemoveItems = * Represents primitive types in TypeScript. */ export type Primitive = string | number | boolean | symbol | null | undefined; + +/** + * Deeply merges two types, recursively combining their properties. + * + * @template T - The first type to merge. + * @template U - The second type to merge. + * + * @remarks + * This type does the following: + * 1. If both T and U are objects, it recursively merges their properties. + * 2. If either T or U is not an object, U takes precedence. + * 3. For arrays, they are merged by creating a union of their element types. + * + * @example + * type A = { a: number; b: { c: string; } }; + * type B = { b: { d: boolean; }; e: string; }; + * type Merged = DeepMerge; + * // { a: number; b: { c: string; d: boolean; }; e: string; } + * + * @returns A new type representing the deep merge of T and U. + */ +export type DeepMerge = [T, U] extends [object, object] + ? T extends readonly (infer T1)[] + ? U extends readonly (infer U1)[] ? Array // 配列同士の場合は要素型をマージ + : U + : U extends readonly (infer U1)[] ? U + : { + [K in keyof T | keyof U]: K extends keyof U + ? K extends keyof T + ? T[K] extends object ? U[K] extends object ? DeepMerge + : U[K] + : U[K] + : U[K] + : K extends keyof T ? T[K] + : never; + } + : U;