diff --git a/src/types/DeepMerge.ts b/src/types/DeepMerge.ts new file mode 100644 index 0000000..3a417d6 --- /dev/null +++ b/src/types/DeepMerge.ts @@ -0,0 +1,73 @@ +namespace DeepMerge { + /** + * @title Infer Type + * + * A helper type that recursively merges two object types, `Target` and `Source`, at the deepest level. + * Unlike {@link DeepStrictMerge.Infer}, when both sides have a common key, **Source takes precedence** + * for primitive values (override/spread pattern). For nested objects, they are recursively merged + * with Source still winning on conflicts. + * + * Type mismatch rules (different from DeepStrictMerge): + * - If one side is an array and the other is not: Source wins + * - If one side is an object and the other is a primitive: Source wins + */ + export type Infer = { + [key in keyof Target | keyof Source]: key extends keyof Source + ? key extends keyof Target + ? // Key exists in both — Source wins, but recurse if both are non-Date non-array objects + Target[key] extends object + ? Source[key] extends object + ? Target[key] extends Date + ? Source[key] // Target is Date: Source wins + : Source[key] extends Date + ? Source[key] // Source is Date: Source wins + : Target[key] extends Array + ? Source[key] extends Array + ? Array> // Both arrays of objects: merge elements + : Source[key] // Array mismatch: Source wins + : Source[key] extends Array + ? Source[key] // Source is array, Target is not: Source wins + : Infer // Both plain objects: recurse + : Source[key] // Target is object, Source is not: Source wins + : Source[key] // Target is not object: Source wins + : Source[key] // Key only in Source + : key extends keyof Target + ? Target[key] // Key only in Target + : never; + }; +} + +/** + * @title DeepMerge Type (Source Wins) + * + * A type that deeply merges two object types, `Target` and `Source`, where **Source takes precedence** + * on overlapping keys. This follows the JavaScript spread/Object.assign pattern: `{...target, ...source}`. + * + * Merge Rules: + * 1. For overlapping keys with both sides being non-array, non-Date objects: recursively merge. + * 2. For overlapping keys with both sides being arrays of objects: merge the element types. + * 3. For all other overlapping cases (type mismatches, primitives): Source wins. + * 4. Non-overlapping keys are preserved from whichever side has them. + * + * Compare with {@link DeepStrictMerge} where Target wins on overlap. + * + * @template Target - The base object type. + * @template Source - The override object type. Its values take precedence on overlapping keys. + * @returns A deeply merged object type combining `Target` and `Source` + * + * @example + * ```ts + * type Ex1 = DeepMerge<{ a: 1 }, { b: 2 }>; // { a: 1; b: 2 } + * type Ex2 = DeepMerge<{ a: { b: 1 } }, { a: { c: 2 } }>; // { a: { b: 1; c: 2 } } + * type Ex3 = DeepMerge<{ a: 1 }, { a: 2 }>; // { a: 2 } (Source wins) + * type Ex4 = DeepMerge<{ a: number[] }, { a: string }>; // { a: string } (Source wins on mismatch) + * ``` + */ +export type DeepMerge = + Target extends Array + ? Source extends Array + ? Array> // Both arrays: merge element types + : Source // Target is array, Source is not: Source wins + : Source extends Array + ? Source // Target is not array, Source is array: Source wins + : DeepMerge.Infer; diff --git a/src/types/DeepOmit.ts b/src/types/DeepOmit.ts new file mode 100644 index 0000000..bb8e301 --- /dev/null +++ b/src/types/DeepOmit.ts @@ -0,0 +1,68 @@ +import type { DeepStrictObjectKeys } from './DeepStrictObjectKeys'; +import type { GetElementMember } from './GetMember'; + +namespace DeepOmit { + /** + * @internal Recursively omits keys from a non-array object type. + * + * Unlike {@link DeepStrictOmit.Infer}, this version accepts any string as K, + * silently ignoring keys that do not exist in T. The guard conditions + * (`GetElementMember extends DeepStrictObjectKeys`) naturally + * handle invalid keys by falling through to the else branch which preserves + * the value unchanged. + * + * @template T - The object type to omit keys from + * @template K - The dot-notation key paths to omit (any string; invalid keys are ignored) + */ + export type Infer = '*' extends K + ? {} + : [K] extends [never] + ? T + : { + [key in keyof T as key extends K ? never : key]: T[key] extends Array + ? key extends string + ? Element extends Date + ? Array + : GetElementMember extends DeepStrictObjectKeys + ? Array>> + : Array + : never + : T[key] extends Array + ? Array + : T[key] extends object + ? key extends string + ? T[key] extends Date + ? T[key] + : GetElementMember extends DeepStrictObjectKeys + ? Infer> + : T[key] + : never + : T[key]; + }; +} + +/** + * @title Type for Removing Specific Keys from an Interface (Non-Strict). + * + * The `DeepOmit` type creates a new type by excluding properties + * corresponding to the key `K` from the object `T`, while preserving the nested structure. + * Unlike {@link DeepStrictOmit}, `K` is not constrained to valid keys of `T`. + * Invalid or non-existent key paths in `K` are silently ignored. + * + * {@link DeepStrictObjectKeys} can be used to determine valid keys for omission, + * including nested keys represented with dot notation (`.`) and array indices represented with `[*]`. + * + * Example Usage: + * ```ts + * type Example1 = DeepOmit<{ a: { b: 1; c: 2 } }, "a.b">; // { a: { c: 2 } } + * type Example2 = DeepOmit<{ a: { b: 1; c: 2 } }, "a.b" | "x.y">; // { a: { c: 2 } } (invalid "x.y" ignored) + * type Example3 = DeepOmit<{ a: 1 }, "nonexistent">; // { a: 1 } (no change) + * ``` + */ +export type DeepOmit = '*' extends K + ? T extends Array + ? never[] + : {} + : T extends Array + ? Array extends string ? GetElementMember : never>> + : DeepOmit.Infer; diff --git a/src/types/DeepPick.ts b/src/types/DeepPick.ts new file mode 100644 index 0000000..ccdf8a8 --- /dev/null +++ b/src/types/DeepPick.ts @@ -0,0 +1,37 @@ +import type { DeepStrictObjectKeys } from './DeepStrictObjectKeys'; +import type { DeepOmit } from './DeepOmit'; +import type { DeepStrictUnbrand } from './DeepStrictUnbrand'; +import type { ExpandGlob } from './ExpandGlob'; +import type { RemoveAfterDot } from './RemoveAfterDot'; +import type { RemoveLastProperty } from './RemoveLastProperty'; + +/** + * @title Type for Selecting Specific Keys from an Interface (Non-Strict). + * + * The `DeepPick` type creates a new type by selecting only the properties + * corresponding to the key `K` from the object `T`, while preserving the nested structure. + * Unlike {@link DeepStrictPick}, `K` is not constrained to valid keys of `T`. + * Invalid or non-existent key paths in `K` are silently ignored. + * + * `DeepPick` is implemented by omitting all keys except those selected, + * using {@link DeepOmit} internally. + * + * Example Usage: + * ```ts + * type Example1 = DeepPick<{ a: { b: 1; c: 2 } }, "a.b">; // { a: { b: 1 } } + * type Example2 = DeepPick<{ a: { b: 1; c: 2 } }, "a.b" | "x.y">; // { a: { b: 1 } } (invalid "x.y" ignored) + * type Example3 = DeepPick<{ a: 1; b: 2 }, "nonexistent">; // {} (nothing matched) + * ``` + */ +export type DeepPick = '*' extends K + ? T + : DeepOmit< + T, + Exclude< + Exclude< + DeepStrictObjectKeys, + K | RemoveLastProperty | RemoveAfterDot, K> | ExpandGlob + >, + '*' | `${string}.*` + > + >; diff --git a/src/types/index.ts b/src/types/index.ts index 2ca88a0..d3e985e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,7 @@ export * from './DeepDateToString'; +export * from './DeepMerge'; +export * from './DeepOmit'; +export * from './DeepPick'; export * from './DeepStrictMerge'; export * from './DeepStrictObjectKeys'; export * from './DeepStrictObjectLastKeys'; diff --git a/test/features/DeepMerge.ts b/test/features/DeepMerge.ts new file mode 100644 index 0000000..13a0b95 --- /dev/null +++ b/test/features/DeepMerge.ts @@ -0,0 +1,111 @@ +import { ok } from 'assert'; +import typia from 'typia'; +import { DeepMerge, Equal } from '../../src'; + +/** + * Tests that DeepMerge correctly merges two objects with disjoint keys. + */ +export function test_types_deep_merge_disjoint_keys() { + type Question = DeepMerge<{ a: number }, { b: string }>; + type Answer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepMerge gives Source precedence for overlapping primitive keys. + */ +export function test_types_deep_merge_source_wins_primitive() { + type Question = DeepMerge<{ a: number }, { a: string }>; + type Answer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepMerge recursively merges nested objects with disjoint keys. + */ +export function test_types_deep_merge_nested_disjoint() { + type Question = DeepMerge<{ a: { b: number } }, { a: { c: string } }>; + type Answer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepMerge recursively merges nested objects with overlapping keys (Source wins). + */ +export function test_types_deep_merge_nested_overlapping_source_wins() { + type Question = DeepMerge<{ a: { b: number; c: string } }, { a: { b: string; d: boolean } }>; + type Answer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepMerge merges top-level arrays of objects. + */ +export function test_types_deep_merge_array_top_level() { + type Question = DeepMerge<{ a: number }[], { b: string }[]>; + type Answer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepMerge merges array-typed properties within objects. + */ +export function test_types_deep_merge_array_property() { + type Question = DeepMerge<{ items: { a: number }[] }, { items: { b: string }[] }>; + type Answer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepMerge returns Source when Target is array but Source is not (instead of never). + */ +export function test_types_deep_merge_array_vs_non_array_source_wins() { + type Question = DeepMerge<{ a: number }[], { b: string }>; + type Answer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepMerge returns Source when Target is not array but Source is. + */ +export function test_types_deep_merge_non_array_vs_array_source_wins() { + type Question = DeepMerge<{ a: number }, { a: string }[]>; + type Answer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepMerge handles deeply nested structures (3+ levels). + */ +export function test_types_deep_merge_deeply_nested() { + type Question = DeepMerge<{ a: { b: { c: number } } }, { a: { b: { d: string } } }>; + type Answer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepMerge deeply nested with overlapping keys has Source win. + */ +export function test_types_deep_merge_deeply_nested_source_override() { + type Question = DeepMerge<{ a: { b: { c: number } } }, { a: { b: { c: string } } }>; + type Answer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepMerge preserves Source-only keys at nested levels. + */ +export function test_types_deep_merge_source_only_nested() { + type Question = DeepMerge<{ a: { b: number } }, { a: { c: string }; d: boolean }>; + type Answer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepMerge merges array element types with Source winning on overlap. + */ +export function test_types_deep_merge_array_element_overlapping() { + type Question = DeepMerge<{ items: { id: number; name: string }[] }, { items: { id: string; active: boolean }[] }>; + type Answer = Equal; + ok(typia.random()); +} diff --git a/test/features/DeepOmit.ts b/test/features/DeepOmit.ts new file mode 100644 index 0000000..25a9436 --- /dev/null +++ b/test/features/DeepOmit.ts @@ -0,0 +1,120 @@ +import { ok } from 'assert'; +import typia from 'typia'; +import { DeepOmit, Equal } from '../../src'; + +/** + * Tests that DeepOmit correctly omits a simple single key. + */ +export function test_types_deep_omit_simple_single() { + type Question = DeepOmit<{ a: number; b: string }, 'a'>; + type IsAnswer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepOmit correctly omits a nested key. + */ +export function test_types_deep_omit_nested() { + type Question = DeepOmit<{ a: { b: 1; c: 2 } }, 'a.b'>; + type IsAnswer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepOmit correctly omits a nested property from array elements. + */ +export function test_types_deep_omit_nested_array_property() { + type Question = DeepOmit<{ items: { id: number; name: string }[] }, 'items[*].id'>; + type IsAnswer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepOmit works on root-level arrays with [*] notation. + */ +export function test_types_deep_omit_root_array() { + type Question = DeepOmit<{ a: number; b: string }[], '[*].a'>; + type IsAnswer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepOmit works at 3 depth levels. + */ +export function test_types_deep_omit_three_depth() { + type Question = DeepOmit<{ a: { b: { c: number; d: string } } }, 'a.b.c'>; + type IsAnswer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepOmit preserves Date types without recursing. + */ +export function test_types_deep_omit_preserves_date() { + type Question = DeepOmit<{ created: Date; name: string; updated: Date }, 'name'>; + type IsAnswer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepOmit with '*' returns empty object. + */ +export function test_types_deep_omit_glob_all() { + type Question = DeepOmit<{ a: number; b: string }, '*'>; + type IsAnswer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepOmit can omit multiple nested keys simultaneously. + */ +export function test_types_deep_omit_multiple_nested() { + type Question = DeepOmit<{ a: { b: number; c: string; d: boolean } }, 'a.b' | 'a.d'>; + type IsAnswer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepOmit silently ignores an invalid key. + */ +export function test_types_deep_omit_invalid_key_ignored() { + type Question = DeepOmit<{ a: number; b: string }, 'nonexistent'>; + type IsAnswer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepOmit handles a mix of valid and invalid keys. + */ +export function test_types_deep_omit_mix_valid_and_invalid_keys() { + type Question = DeepOmit<{ a: number; b: string; c: boolean }, 'a' | 'x.y.z'>; + type IsAnswer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepOmit with all invalid keys returns the original type unchanged. + */ +export function test_types_deep_omit_all_invalid_keys() { + type Question = DeepOmit<{ a: number; b: string }, 'foo' | 'bar.baz'>; + type IsAnswer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepOmit silently ignores an invalid nested key. + */ +export function test_types_deep_omit_invalid_nested_key() { + type Question = DeepOmit<{ a: { b: 1; c: 2 } }, 'a.nonexistent'>; + type IsAnswer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepOmit handles valid nested key alongside invalid sibling key. + */ +export function test_types_deep_omit_valid_nested_with_invalid_sibling() { + type Question = DeepOmit<{ a: { b: 1; c: 2 }; d: 3 }, 'a.b' | 'e.f'>; + type IsAnswer = Equal; + ok(typia.random()); +} diff --git a/test/features/DeepPick.ts b/test/features/DeepPick.ts new file mode 100644 index 0000000..6e12d09 --- /dev/null +++ b/test/features/DeepPick.ts @@ -0,0 +1,120 @@ +import { ok } from 'assert'; +import typia from 'typia'; +import { DeepPick, Equal } from '../../src'; + +/** + * Tests that DeepPick correctly picks a single top-level key. + */ +export function test_types_deep_pick_simple_single() { + type Question = DeepPick<{ a: number; b: string; c: boolean }, 'a'>; + type Answer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepPick correctly picks a nested key. + */ +export function test_types_deep_pick_nested() { + type Question = DeepPick<{ a: { b: 1; c: 2 } }, 'a.b'>; + type Answer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepPick picks a property from deeply nested array elements. + */ +export function test_types_deep_pick_nested_array() { + type Question = DeepPick<{ data: { items: { id: number; name: string }[] } }, 'data.items[*].id'>; + type Answer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepPick works on root-level arrays. + */ +export function test_types_deep_pick_root_array() { + type Question = DeepPick<{ a: 1 }[], '[*].a'>; + type Answer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepPick works at 3 levels of nesting. + */ +export function test_types_deep_pick_three_levels() { + type Question = DeepPick<{ a: { b: { c: number; d: string } } }, 'a.b.c'>; + type Answer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepPick preserves Date types. + */ +export function test_types_deep_pick_preserves_date() { + type Question = DeepPick<{ a: Date; b: number }, 'a'>; + type Answer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepPick with '*' returns the full object. + */ +export function test_types_deep_pick_glob_all() { + type Question = DeepPick<{ a: number; b: string }, '*'>; + type Answer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepPick with 'a.*' picks the entire nested object. + */ +export function test_types_deep_pick_glob_nested() { + type Question = DeepPick<{ a: { b: 1; c: 2 }; d: 3 }, 'a.*'>; + type Answer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepPick picks multiple nested keys from the same parent. + */ +export function test_types_deep_pick_multiple_from_same_parent() { + type Question = DeepPick<{ a: { b: 1; c: 2; d: 3 } }, 'a.b' | 'a.c'>; + type Answer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepPick with an invalid key returns empty object. + */ +export function test_types_deep_pick_invalid_key_returns_empty() { + type Question = DeepPick<{ a: number; b: string }, 'nonexistent'>; + type Answer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepPick handles a mix of valid and invalid keys. + */ +export function test_types_deep_pick_mix_valid_and_invalid_keys() { + type Question = DeepPick<{ a: number; b: string; c: boolean }, 'a' | 'x.y.z'>; + type Answer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepPick with all invalid keys returns empty object. + */ +export function test_types_deep_pick_all_invalid_keys() { + type Question = DeepPick<{ a: number; b: string }, 'foo' | 'bar.baz'>; + type Answer = Equal; + ok(typia.random()); +} + +/** + * Tests that DeepPick handles valid nested key alongside invalid sibling key. + */ +export function test_types_deep_pick_valid_nested_with_invalid_sibling() { + type Question = DeepPick<{ a: { b: 1; c: 2 }; d: 3 }, 'a.b' | 'e.f'>; + type Answer = Equal; + ok(typia.random()); +}