Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
73 changes: 73 additions & 0 deletions src/types/DeepMerge.ts
Original file line number Diff line number Diff line change
@@ -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<Target extends object, Source extends object> = {
[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<infer TE extends object>
? Source[key] extends Array<infer SE extends object>
? Array<Infer<TE, SE>> // Both arrays of objects: merge elements
: Source[key] // Array mismatch: Source wins
: Source[key] extends Array<any>
? Source[key] // Source is array, Target is not: Source wins
: Infer<Target[key], Source[key]> // 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 object, Source extends object> =
Target extends Array<infer TE extends object>
? Source extends Array<infer SE extends object>
? Array<DeepMerge.Infer<TE, SE>> // Both arrays: merge element types
: Source // Target is array, Source is not: Source wins
: Source extends Array<any>
? Source // Target is not array, Source is array: Source wins
: DeepMerge.Infer<Target, Source>;
68 changes: 68 additions & 0 deletions src/types/DeepOmit.ts
Original file line number Diff line number Diff line change
@@ -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<K, key> extends DeepStrictObjectKeys<Element>`) 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<T extends object, K extends string> = '*' extends K
? {}
: [K] extends [never]
? T
: {
[key in keyof T as key extends K ? never : key]: T[key] extends Array<infer Element extends object>
? key extends string
? Element extends Date
? Array<Element>
: GetElementMember<K, key> extends DeepStrictObjectKeys<Element>
? Array<Infer<Element, GetElementMember<K, key>>>
: Array<Element>
: never
: T[key] extends Array<infer Element>
? Array<Element>
: T[key] extends object
? key extends string
? T[key] extends Date
? T[key]
: GetElementMember<K, key> extends DeepStrictObjectKeys<T[key]>
? Infer<T[key], GetElementMember<K, key>>
: T[key]
: never
: T[key];
};
}

/**
* @title Type for Removing Specific Keys from an Interface (Non-Strict).
*
* The `DeepOmit<T, K>` 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<T extends object, K extends string> = '*' extends K
? T extends Array<any>
? never[]
: {}
: T extends Array<infer Element extends object>
? Array<DeepOmit<Element, GetElementMember<K, ''> extends string ? GetElementMember<K, ''> : never>>
: DeepOmit.Infer<T, K>;
37 changes: 37 additions & 0 deletions src/types/DeepPick.ts
Original file line number Diff line number Diff line change
@@ -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<T, K>` 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<T extends object, K extends string> = '*' extends K
? T
: DeepOmit<
T,
Exclude<
Exclude<
DeepStrictObjectKeys<T>,
K | RemoveLastProperty<K> | RemoveAfterDot<DeepStrictUnbrand<T>, K> | ExpandGlob<K>
>,
'*' | `${string}.*`
>
>;
3 changes: 3 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
111 changes: 111 additions & 0 deletions test/features/DeepMerge.ts
Original file line number Diff line number Diff line change
@@ -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<Question, { a: number; b: string }>;
ok(typia.random<Answer>());
}

/**
* 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<Question, { a: string }>;
ok(typia.random<Answer>());
}

/**
* 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<Question, { a: { b: number; c: string } }>;
ok(typia.random<Answer>());
}

/**
* 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<Question, { a: { b: string; c: string; d: boolean } }>;
ok(typia.random<Answer>());
}

/**
* 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<Question, { a: number; b: string }[]>;
ok(typia.random<Answer>());
}

/**
* 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<Question, { items: { a: number; b: string }[] }>;
ok(typia.random<Answer>());
}

/**
* 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<Question, { b: string }>;
ok(typia.random<Answer>());
}

/**
* 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<Question, { a: string }[]>;
ok(typia.random<Answer>());
}

/**
* 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<Question, { a: { b: { c: number; d: string } } }>;
ok(typia.random<Answer>());
}

/**
* 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<Question, { a: { b: { c: string } } }>;
ok(typia.random<Answer>());
}

/**
* 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<Question, { a: { b: number; c: string }; d: boolean }>;
ok(typia.random<Answer>());
}

/**
* 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<Question, { items: { id: string; name: string; active: boolean }[] }>;
ok(typia.random<Answer>());
}
Loading