Custom Class Handling
Relevant source files
The following files were used as context for generating this wiki page:
In keyfold, custom class handling refers to the library's mechanism for distinguishing between "plain objects" that should be deep-merged and "opaque values" (such as class instances) that should be replaced wholesale. This boundary ensures that internal class structures are not corrupted by recursive merging logic and that architectural boundaries—like those defined by domain models—are respected during state updates.
The system relies on a combination of runtime checks and static type definitions to determine how a value should be treated during a fold operation. While plain objects and specific built-in types are handled automatically, developers can explicitly define custom classes as Atomic to enforce wholesale replacement in the delta type.
Sources: README.md:104-124, AGENTS.md:21-23, src/objects.ts:7-14
Atomic vs. Mergable Types
The library differentiates types based on their structural nature and prototype. Values identified as "Atomic" or "Opaque" act as boundaries for recursion; the merger swaps these values verbatim rather than traversing their properties.
Plain Objects
A "plain object" is a record safe to recurse into. At runtime, keyfold identifies these as objects whose prototype is either Object.prototype or null. These objects are deep-merged using their own enumerable string and symbol keys.
Sources: src/objects.ts:11-14, src/merge.ts:98-100, test/merge.test.ts:60-70
Built-in Opaque Types
Several JavaScript built-ins are treated as atomic by default because their internal structures are not suitable for structural merging. These include:
Date,Error,RegExpMap,Set,WeakMap,WeakSetPromise,ArrayBuffer,ArrayBufferView- Functions
Sources: src/types.ts:47-58, README.md:117-118
Explicit Atomic Declaration
Custom classes and application-specific opaque types should be declared using the Atomic type parameter in createMerger. This ensures that TypeScript requires a complete instance in the Delta<T> rather than a partial structural match.
The diagram above illustrates the decision logic for determining whether to merge or replace a value.
Sources: src/objects.ts:11-14, src/merge.ts:49-65, README.md:104-124
Implementation Details
Runtime Detection
The isPlainObject function performs the primary runtime check. By verifying that the prototype is specifically Object.prototype or null, the library effectively excludes class instances (which have a custom prototype) from recursive merging.
Sources: src/objects.ts:11-14, AGENTS.md:21-23
Type Logic for Atomic Values
The Delta<T, Atomic> type uses conditional types to determine the shape of the delta. If a type T extends the Atomic type parameter, it is preserved as T instead of being transformed into a partial DeltaObject.
| Type Category | Delta Transformation | Behavior |
|---|---|---|
| Plain Object | DeltaObject<T> | Recursive merge of fields |
| Atomic/Class | T | Wholesale replacement |
| Keyed Item | Delta<Element> & { $delete? } | Reconciliation or Merge |
| Unkeyed Array | T | Wholesale replacement |
Sources: src/types.ts:25-45, test/types.ts:60-78
Structural Typing Limitations
Because TypeScript uses structural typing, a subclass or an object literal that matches a class's public surface might be accepted as a valid delta. To enforce strict class identity, it is recommended to use private fields (e.g., #nominal = true) in the class definition.
Sources: README.md:120-124, test/types.ts:80-95
Usage Example: Domain Models
When working with domain models like a Money or User class, declaring them as atomic prevents the merger from attempting to partially update their internal properties, which might violate class invariants.
class Money {
readonly #nominal = true; // Ensures structural uniqueness
constructor(readonly cents: number) {}
}
interface State {
balance: Money;
}
// Declare Money as Atomic to protect it from partial updates
const merge = createMerger<State, Money>();
// This replaces the entire instance
merge(state, { balance: new Money(500) });
Sources: README.md:106-116, test/types.ts:60-65
Conclusion
Custom class handling in keyfold is a protective measure designed to maintain the integrity of complex data structures. By treating non-plain objects as atomic boundaries, the library ensures that class instances are replaced rather than merged, preserving their prototypes and internal consistency. This behavior is configurable through the Atomic type parameter, allowing developers to precisely define where recursion should stop.
Sources: AGENTS.md:21-23, README.md:104-124, src/merge.ts:49-51