Skip to content

Commit 6804fad

Browse files
authored
Merge pull request #29 from kakasoo/kakasoo/add-deep-non-strict
feat: add DeepPick, DeepOmit, DeepMerge non-strict type utilities
2 parents 70fd096 + e318bc1 commit 6804fad

File tree

7 files changed

+532
-0
lines changed

7 files changed

+532
-0
lines changed

src/types/DeepMerge.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
namespace DeepMerge {
2+
/**
3+
* @title Infer Type
4+
*
5+
* A helper type that recursively merges two object types, `Target` and `Source`, at the deepest level.
6+
* Unlike {@link DeepStrictMerge.Infer}, when both sides have a common key, **Source takes precedence**
7+
* for primitive values (override/spread pattern). For nested objects, they are recursively merged
8+
* with Source still winning on conflicts.
9+
*
10+
* Type mismatch rules (different from DeepStrictMerge):
11+
* - If one side is an array and the other is not: Source wins
12+
* - If one side is an object and the other is a primitive: Source wins
13+
*/
14+
export type Infer<Target extends object, Source extends object> = {
15+
[key in keyof Target | keyof Source]: key extends keyof Source
16+
? key extends keyof Target
17+
? // Key exists in both — Source wins, but recurse if both are non-Date non-array objects
18+
Target[key] extends object
19+
? Source[key] extends object
20+
? Target[key] extends Date
21+
? Source[key] // Target is Date: Source wins
22+
: Source[key] extends Date
23+
? Source[key] // Source is Date: Source wins
24+
: Target[key] extends Array<infer TE extends object>
25+
? Source[key] extends Array<infer SE extends object>
26+
? Array<Infer<TE, SE>> // Both arrays of objects: merge elements
27+
: Source[key] // Array mismatch: Source wins
28+
: Source[key] extends Array<any>
29+
? Source[key] // Source is array, Target is not: Source wins
30+
: Infer<Target[key], Source[key]> // Both plain objects: recurse
31+
: Source[key] // Target is object, Source is not: Source wins
32+
: Source[key] // Target is not object: Source wins
33+
: Source[key] // Key only in Source
34+
: key extends keyof Target
35+
? Target[key] // Key only in Target
36+
: never;
37+
};
38+
}
39+
40+
/**
41+
* @title DeepMerge Type (Source Wins)
42+
*
43+
* A type that deeply merges two object types, `Target` and `Source`, where **Source takes precedence**
44+
* on overlapping keys. This follows the JavaScript spread/Object.assign pattern: `{...target, ...source}`.
45+
*
46+
* Merge Rules:
47+
* 1. For overlapping keys with both sides being non-array, non-Date objects: recursively merge.
48+
* 2. For overlapping keys with both sides being arrays of objects: merge the element types.
49+
* 3. For all other overlapping cases (type mismatches, primitives): Source wins.
50+
* 4. Non-overlapping keys are preserved from whichever side has them.
51+
*
52+
* Compare with {@link DeepStrictMerge} where Target wins on overlap.
53+
*
54+
* @template Target - The base object type.
55+
* @template Source - The override object type. Its values take precedence on overlapping keys.
56+
* @returns A deeply merged object type combining `Target` and `Source`
57+
*
58+
* @example
59+
* ```ts
60+
* type Ex1 = DeepMerge<{ a: 1 }, { b: 2 }>; // { a: 1; b: 2 }
61+
* type Ex2 = DeepMerge<{ a: { b: 1 } }, { a: { c: 2 } }>; // { a: { b: 1; c: 2 } }
62+
* type Ex3 = DeepMerge<{ a: 1 }, { a: 2 }>; // { a: 2 } (Source wins)
63+
* type Ex4 = DeepMerge<{ a: number[] }, { a: string }>; // { a: string } (Source wins on mismatch)
64+
* ```
65+
*/
66+
export type DeepMerge<Target extends object, Source extends object> =
67+
Target extends Array<infer TE extends object>
68+
? Source extends Array<infer SE extends object>
69+
? Array<DeepMerge.Infer<TE, SE>> // Both arrays: merge element types
70+
: Source // Target is array, Source is not: Source wins
71+
: Source extends Array<any>
72+
? Source // Target is not array, Source is array: Source wins
73+
: DeepMerge.Infer<Target, Source>;

src/types/DeepOmit.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { DeepStrictObjectKeys } from './DeepStrictObjectKeys';
2+
import type { GetElementMember } from './GetMember';
3+
4+
namespace DeepOmit {
5+
/**
6+
* @internal Recursively omits keys from a non-array object type.
7+
*
8+
* Unlike {@link DeepStrictOmit.Infer}, this version accepts any string as K,
9+
* silently ignoring keys that do not exist in T. The guard conditions
10+
* (`GetElementMember<K, key> extends DeepStrictObjectKeys<Element>`) naturally
11+
* handle invalid keys by falling through to the else branch which preserves
12+
* the value unchanged.
13+
*
14+
* @template T - The object type to omit keys from
15+
* @template K - The dot-notation key paths to omit (any string; invalid keys are ignored)
16+
*/
17+
export type Infer<T extends object, K extends string> = '*' extends K
18+
? {}
19+
: [K] extends [never]
20+
? T
21+
: {
22+
[key in keyof T as key extends K ? never : key]: T[key] extends Array<infer Element extends object>
23+
? key extends string
24+
? Element extends Date
25+
? Array<Element>
26+
: GetElementMember<K, key> extends DeepStrictObjectKeys<Element>
27+
? Array<Infer<Element, GetElementMember<K, key>>>
28+
: Array<Element>
29+
: never
30+
: T[key] extends Array<infer Element>
31+
? Array<Element>
32+
: T[key] extends object
33+
? key extends string
34+
? T[key] extends Date
35+
? T[key]
36+
: GetElementMember<K, key> extends DeepStrictObjectKeys<T[key]>
37+
? Infer<T[key], GetElementMember<K, key>>
38+
: T[key]
39+
: never
40+
: T[key];
41+
};
42+
}
43+
44+
/**
45+
* @title Type for Removing Specific Keys from an Interface (Non-Strict).
46+
*
47+
* The `DeepOmit<T, K>` type creates a new type by excluding properties
48+
* corresponding to the key `K` from the object `T`, while preserving the nested structure.
49+
* Unlike {@link DeepStrictOmit}, `K` is not constrained to valid keys of `T`.
50+
* Invalid or non-existent key paths in `K` are silently ignored.
51+
*
52+
* {@link DeepStrictObjectKeys} can be used to determine valid keys for omission,
53+
* including nested keys represented with dot notation (`.`) and array indices represented with `[*]`.
54+
*
55+
* Example Usage:
56+
* ```ts
57+
* type Example1 = DeepOmit<{ a: { b: 1; c: 2 } }, "a.b">; // { a: { c: 2 } }
58+
* type Example2 = DeepOmit<{ a: { b: 1; c: 2 } }, "a.b" | "x.y">; // { a: { c: 2 } } (invalid "x.y" ignored)
59+
* type Example3 = DeepOmit<{ a: 1 }, "nonexistent">; // { a: 1 } (no change)
60+
* ```
61+
*/
62+
export type DeepOmit<T extends object, K extends string> = '*' extends K
63+
? T extends Array<any>
64+
? never[]
65+
: {}
66+
: T extends Array<infer Element extends object>
67+
? Array<DeepOmit<Element, GetElementMember<K, ''> extends string ? GetElementMember<K, ''> : never>>
68+
: DeepOmit.Infer<T, K>;

src/types/DeepPick.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { DeepStrictObjectKeys } from './DeepStrictObjectKeys';
2+
import type { DeepOmit } from './DeepOmit';
3+
import type { DeepStrictUnbrand } from './DeepStrictUnbrand';
4+
import type { ExpandGlob } from './ExpandGlob';
5+
import type { RemoveAfterDot } from './RemoveAfterDot';
6+
import type { RemoveLastProperty } from './RemoveLastProperty';
7+
8+
/**
9+
* @title Type for Selecting Specific Keys from an Interface (Non-Strict).
10+
*
11+
* The `DeepPick<T, K>` type creates a new type by selecting only the properties
12+
* corresponding to the key `K` from the object `T`, while preserving the nested structure.
13+
* Unlike {@link DeepStrictPick}, `K` is not constrained to valid keys of `T`.
14+
* Invalid or non-existent key paths in `K` are silently ignored.
15+
*
16+
* `DeepPick` is implemented by omitting all keys except those selected,
17+
* using {@link DeepOmit} internally.
18+
*
19+
* Example Usage:
20+
* ```ts
21+
* type Example1 = DeepPick<{ a: { b: 1; c: 2 } }, "a.b">; // { a: { b: 1 } }
22+
* type Example2 = DeepPick<{ a: { b: 1; c: 2 } }, "a.b" | "x.y">; // { a: { b: 1 } } (invalid "x.y" ignored)
23+
* type Example3 = DeepPick<{ a: 1; b: 2 }, "nonexistent">; // {} (nothing matched)
24+
* ```
25+
*/
26+
export type DeepPick<T extends object, K extends string> = '*' extends K
27+
? T
28+
: DeepOmit<
29+
T,
30+
Exclude<
31+
Exclude<
32+
DeepStrictObjectKeys<T>,
33+
K | RemoveLastProperty<K> | RemoveAfterDot<DeepStrictUnbrand<T>, K> | ExpandGlob<K>
34+
>,
35+
'*' | `${string}.*`
36+
>
37+
>;

src/types/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
export * from './DeepDateToString';
2+
export * from './DeepMerge';
3+
export * from './DeepOmit';
4+
export * from './DeepPick';
25
export * from './DeepStrictMerge';
36
export * from './DeepStrictObjectKeys';
47
export * from './DeepStrictObjectLastKeys';

test/features/DeepMerge.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { ok } from 'assert';
2+
import typia from 'typia';
3+
import { DeepMerge, Equal } from '../../src';
4+
5+
/**
6+
* Tests that DeepMerge correctly merges two objects with disjoint keys.
7+
*/
8+
export function test_types_deep_merge_disjoint_keys() {
9+
type Question = DeepMerge<{ a: number }, { b: string }>;
10+
type Answer = Equal<Question, { a: number; b: string }>;
11+
ok(typia.random<Answer>());
12+
}
13+
14+
/**
15+
* Tests that DeepMerge gives Source precedence for overlapping primitive keys.
16+
*/
17+
export function test_types_deep_merge_source_wins_primitive() {
18+
type Question = DeepMerge<{ a: number }, { a: string }>;
19+
type Answer = Equal<Question, { a: string }>;
20+
ok(typia.random<Answer>());
21+
}
22+
23+
/**
24+
* Tests that DeepMerge recursively merges nested objects with disjoint keys.
25+
*/
26+
export function test_types_deep_merge_nested_disjoint() {
27+
type Question = DeepMerge<{ a: { b: number } }, { a: { c: string } }>;
28+
type Answer = Equal<Question, { a: { b: number; c: string } }>;
29+
ok(typia.random<Answer>());
30+
}
31+
32+
/**
33+
* Tests that DeepMerge recursively merges nested objects with overlapping keys (Source wins).
34+
*/
35+
export function test_types_deep_merge_nested_overlapping_source_wins() {
36+
type Question = DeepMerge<{ a: { b: number; c: string } }, { a: { b: string; d: boolean } }>;
37+
type Answer = Equal<Question, { a: { b: string; c: string; d: boolean } }>;
38+
ok(typia.random<Answer>());
39+
}
40+
41+
/**
42+
* Tests that DeepMerge merges top-level arrays of objects.
43+
*/
44+
export function test_types_deep_merge_array_top_level() {
45+
type Question = DeepMerge<{ a: number }[], { b: string }[]>;
46+
type Answer = Equal<Question, { a: number; b: string }[]>;
47+
ok(typia.random<Answer>());
48+
}
49+
50+
/**
51+
* Tests that DeepMerge merges array-typed properties within objects.
52+
*/
53+
export function test_types_deep_merge_array_property() {
54+
type Question = DeepMerge<{ items: { a: number }[] }, { items: { b: string }[] }>;
55+
type Answer = Equal<Question, { items: { a: number; b: string }[] }>;
56+
ok(typia.random<Answer>());
57+
}
58+
59+
/**
60+
* Tests that DeepMerge returns Source when Target is array but Source is not (instead of never).
61+
*/
62+
export function test_types_deep_merge_array_vs_non_array_source_wins() {
63+
type Question = DeepMerge<{ a: number }[], { b: string }>;
64+
type Answer = Equal<Question, { b: string }>;
65+
ok(typia.random<Answer>());
66+
}
67+
68+
/**
69+
* Tests that DeepMerge returns Source when Target is not array but Source is.
70+
*/
71+
export function test_types_deep_merge_non_array_vs_array_source_wins() {
72+
type Question = DeepMerge<{ a: number }, { a: string }[]>;
73+
type Answer = Equal<Question, { a: string }[]>;
74+
ok(typia.random<Answer>());
75+
}
76+
77+
/**
78+
* Tests that DeepMerge handles deeply nested structures (3+ levels).
79+
*/
80+
export function test_types_deep_merge_deeply_nested() {
81+
type Question = DeepMerge<{ a: { b: { c: number } } }, { a: { b: { d: string } } }>;
82+
type Answer = Equal<Question, { a: { b: { c: number; d: string } } }>;
83+
ok(typia.random<Answer>());
84+
}
85+
86+
/**
87+
* Tests that DeepMerge deeply nested with overlapping keys has Source win.
88+
*/
89+
export function test_types_deep_merge_deeply_nested_source_override() {
90+
type Question = DeepMerge<{ a: { b: { c: number } } }, { a: { b: { c: string } } }>;
91+
type Answer = Equal<Question, { a: { b: { c: string } } }>;
92+
ok(typia.random<Answer>());
93+
}
94+
95+
/**
96+
* Tests that DeepMerge preserves Source-only keys at nested levels.
97+
*/
98+
export function test_types_deep_merge_source_only_nested() {
99+
type Question = DeepMerge<{ a: { b: number } }, { a: { c: string }; d: boolean }>;
100+
type Answer = Equal<Question, { a: { b: number; c: string }; d: boolean }>;
101+
ok(typia.random<Answer>());
102+
}
103+
104+
/**
105+
* Tests that DeepMerge merges array element types with Source winning on overlap.
106+
*/
107+
export function test_types_deep_merge_array_element_overlapping() {
108+
type Question = DeepMerge<{ items: { id: number; name: string }[] }, { items: { id: string; active: boolean }[] }>;
109+
type Answer = Equal<Question, { items: { id: string; name: string; active: boolean }[] }>;
110+
ok(typia.random<Answer>());
111+
}

0 commit comments

Comments
 (0)