When default value is null but source has a value (e.g., description field), the merge was discarding the source value because typeof null !== typeof string. Now properly accepts source values for nullable fields. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
170 lines
4.6 KiB
TypeScript
170 lines
4.6 KiB
TypeScript
/**
|
|
* Merge With Defaults
|
|
*
|
|
* Utilities for safely merging API data with form defaults.
|
|
* Handles missing fields, type mismatches, and nested objects.
|
|
*
|
|
* @module lib/forms/utils/mergeWithDefaults
|
|
*/
|
|
|
|
/**
|
|
* Safely get a value with type checking and default fallback
|
|
*
|
|
* @param value - Value to check
|
|
* @param defaultValue - Default to use if value is invalid
|
|
* @param typeCheck - Type checking function
|
|
* @returns Valid value or default
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* const temp = safeValue(apiData.temperature, 0.7, (v) => typeof v === 'number');
|
|
* ```
|
|
*/
|
|
export function safeValue<T>(
|
|
value: unknown,
|
|
defaultValue: T,
|
|
typeCheck: (v: unknown) => v is T
|
|
): T {
|
|
return typeCheck(value) ? value : defaultValue;
|
|
}
|
|
|
|
/**
|
|
* Type guard for numbers
|
|
*/
|
|
export function isNumber(v: unknown): v is number {
|
|
return typeof v === 'number' && !Number.isNaN(v);
|
|
}
|
|
|
|
/**
|
|
* Type guard for strings
|
|
*/
|
|
export function isString(v: unknown): v is string {
|
|
return typeof v === 'string';
|
|
}
|
|
|
|
/**
|
|
* Type guard for booleans
|
|
*/
|
|
export function isBoolean(v: unknown): v is boolean {
|
|
return typeof v === 'boolean';
|
|
}
|
|
|
|
/**
|
|
* Type guard for arrays
|
|
*/
|
|
export function isArray<T>(v: unknown, itemCheck?: (item: unknown) => item is T): v is T[] {
|
|
if (!Array.isArray(v)) return false;
|
|
if (itemCheck) return v.every(itemCheck);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Type guard for objects (non-null, non-array)
|
|
*/
|
|
export function isObject(v: unknown): v is Record<string, unknown> {
|
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
}
|
|
|
|
/**
|
|
* Deep merge two objects, with source values taking precedence
|
|
* Only merges values that pass type checking against defaults
|
|
*
|
|
* @param defaults - Default values (used as type template)
|
|
* @param source - Source values to merge (from API)
|
|
* @returns Merged object with all fields from defaults
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* const defaults = { temperature: 0.7, max_tokens: 8192, top_p: 0.95 };
|
|
* const apiData = { temperature: 0.5 }; // missing max_tokens and top_p
|
|
* const merged = deepMergeWithDefaults(defaults, apiData);
|
|
* // { temperature: 0.5, max_tokens: 8192, top_p: 0.95 }
|
|
* ```
|
|
*/
|
|
export function deepMergeWithDefaults<T extends Record<string, unknown>>(
|
|
defaults: T,
|
|
source: Partial<T> | null | undefined
|
|
): T {
|
|
if (!source) return { ...defaults };
|
|
|
|
const result = { ...defaults } as T;
|
|
|
|
for (const key of Object.keys(defaults) as Array<keyof T>) {
|
|
const defaultValue = defaults[key];
|
|
const sourceValue = source[key];
|
|
|
|
// Skip if source doesn't have this key
|
|
if (!(key in source) || sourceValue === undefined) {
|
|
continue;
|
|
}
|
|
|
|
// Handle nested objects recursively
|
|
if (isObject(defaultValue) && isObject(sourceValue)) {
|
|
result[key] = deepMergeWithDefaults(
|
|
defaultValue as Record<string, unknown>,
|
|
sourceValue as Record<string, unknown>
|
|
) as T[keyof T];
|
|
continue;
|
|
}
|
|
|
|
// For primitives and arrays, only use source if types match
|
|
if (typeof sourceValue === typeof defaultValue) {
|
|
result[key] = sourceValue as T[keyof T];
|
|
}
|
|
// Special case: default is null but source has a value (nullable fields)
|
|
else if (defaultValue === null && sourceValue !== null) {
|
|
result[key] = sourceValue as T[keyof T];
|
|
}
|
|
// Special case: allow null for nullable fields
|
|
else if (sourceValue === null && defaultValue === null) {
|
|
result[key] = null as T[keyof T];
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Create a form values initializer from API data
|
|
*
|
|
* This is a higher-order function that creates a type-safe initializer
|
|
* for transforming API responses into form values with defaults.
|
|
*
|
|
* @param defaults - Default form values
|
|
* @param transform - Optional transform function for custom mapping
|
|
* @returns Function that takes API data and returns form values
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* const initializeAgentForm = createFormInitializer(
|
|
* defaultAgentTypeValues,
|
|
* (apiData, defaults) => ({
|
|
* ...defaults,
|
|
* name: apiData?.name ?? defaults.name,
|
|
* model_params: deepMergeWithDefaults(
|
|
* defaults.model_params,
|
|
* apiData?.model_params
|
|
* ),
|
|
* })
|
|
* );
|
|
*
|
|
* // Usage
|
|
* const formValues = initializeAgentForm(apiResponse);
|
|
* ```
|
|
*/
|
|
export function createFormInitializer<TForm, TApi = Partial<TForm>>(
|
|
defaults: TForm,
|
|
transform?: (apiData: TApi | null | undefined, defaults: TForm) => TForm
|
|
): (apiData: TApi | null | undefined) => TForm {
|
|
return (apiData) => {
|
|
if (transform) {
|
|
return transform(apiData, defaults);
|
|
}
|
|
// Default behavior: deep merge
|
|
return deepMergeWithDefaults(
|
|
defaults as Record<string, unknown>,
|
|
apiData as Record<string, unknown> | null | undefined
|
|
) as TForm;
|
|
};
|
|
}
|