diff --git a/libs/common/src/types/options.spec.ts b/libs/common/src/types/options.spec.ts index bb2ad9d9670..27bbfd6b152 100644 --- a/libs/common/src/types/options.spec.ts +++ b/libs/common/src/types/options.spec.ts @@ -20,6 +20,7 @@ const EXAMPLE_DEFAULTS = Object.freeze({ optional_func: () => {}, c: { d: 1, + e: "default", }, f: { h: 1, @@ -39,12 +40,52 @@ describe("mergeOptions", () => { const merged = mergeOptions(options, EXAMPLE_DEFAULTS); // can access properties - expect(merged.a).toBe(42); + expect(merged.a).toBe(0); expect(merged.c.d).toBe(1); expect(merged).toEqual({ a: 0, b: "test", + c: { + d: 1, + e: "default", + }, + f: { + h: 1, + i: "example", + }, + optional_func: EXAMPLE_DEFAULTS.optional_func, + required_func: options.required_func, + }); + }); + + it("maintains required properties under optional properties", () => { + const options: ExampleOptions = { + required_func: () => {}, + b: "test", + c: { + e: "custom", + }, + f: { + i: "example", + }, + }; + + const merged = mergeOptions(options, EXAMPLE_DEFAULTS); + + expect(merged).toEqual({ + a: 0, + b: "test", + c: { + d: 1, // default value from EXAMPLE_DEFAULTS + e: "custom", // overridden by options + }, + f: { + h: 1, // default value from EXAMPLE_DEFAULTS + i: "example", // overridden by options + }, + optional_func: EXAMPLE_DEFAULTS.optional_func, + required_func: options.required_func, }); }); @@ -66,6 +107,16 @@ describe("mergeOptions", () => { expect(merged).toEqual({ a: 42, b: "test", + c: { + d: 1, + e: "default", + }, + f: { + h: 1, + i: "example", + }, + optional_func: EXAMPLE_DEFAULTS.optional_func, + required_func: options.required_func, }); }); diff --git a/libs/common/src/types/options.ts b/libs/common/src/types/options.ts index 04623a87923..a8a3f388f23 100644 --- a/libs/common/src/types/options.ts +++ b/libs/common/src/types/options.ts @@ -32,16 +32,41 @@ export type OptionsWithDefaultsDeep = Simplify< >; type DefaultsForDeep = Simplify< + // Optional properties part Omit< Required<{ [K in keyof Options]: Required[K] extends Primitive | Function ? Options[K] : Required[K] extends object - ? DefaultsForDeep[K]> + ? InternalDefaultsForDeep[K]> : never; // should only ever be primitive, function, or object }>, - RequiredPrimitiveKeysOf | RequiredMethodKeysOf - > + RequiredKeysOf + > & + // Manages properties of object type at any level where all properties up the tree are required + Omit< + Required<{ + [K in keyof Options]: Required[K] extends Primitive | Function + ? Options[K] + : Required[K] extends object + ? DefaultsForDeep[K]> + : never; // should only ever be primitive, function, or object + }>, + RequiredPrimitiveKeysOf | RequiredMethodKeysOf + > +>; + +// Internal helper type to recursively build defaults for nested objects +// This variant does not assume that the Options object defines required properties. +// It is used to build the defaults for nested optional objects in the `DefaultsForDeep` type. +type InternalDefaultsForDeep = Simplify< + Required<{ + [K in keyof Options]: Required[K] extends Primitive | Function + ? Options[K] + : Required[K] extends object + ? InternalDefaultsForDeep[K]> + : never; // should only ever be primitive, function, or object + }> >; // Returns only the keys of `Obj` that are required and not records @@ -54,17 +79,108 @@ type RequiredMethodKeysOf = RequiredKeysOf< Pick> >; +type OptionsObjectFilter = object & { + [K in keyof T]: Required extends Function | Primitive + ? T[K] // primitives and functions are allowed as-is + : Required[K] extends object + ? OptionsObjectFilter // recursively apply to nested objects + : never; // should only ever be primitive, function, or object +}; + +/** + * Merges objects with particular type requirements specific to the case of merging an options object with a defaults object. + * Properties marked as optional in the options object will be required in the defaults object. + * + * Usage here does not, allow the absence of a property in the resolved options to convey meaning. The resolved merged options + * will always have all properties as required. + * + * # Example + * + * ```ts + * import { mergeOptions } from "./options"; + * + * type ExampleOptions = { + * readonly a?: number; + * readonly required_func: () => void; + * readonly optional_func?: () => void; + * readonly b: string; + * readonly c?: { + * readonly d?: number; + * readonly e?: string; + * }; + * readonly f: { + * readonly h?: number; + * readonly i: string; + * }; + * }; + * + * const EXAMPLE_DEFAULTS = Object.freeze({ + * a: 0, + * optional_func: () => {}, + * c: { + * d: 1, + * e: "default" + * }, + * f: { + * h: 1, + * }, + * }); + * + * function test() { + * const options: ExampleOptions = { + * required_func: () => {}, + * b: "test", + * f: { + * i: "example", + * }, + * }; + * expect(mergeOptions(options + * , + * EXAMPLE_DEFAULTS, + * )).toEqual({ + * a: 0, + * b: "test", + * c: { + * d: 1, + * e: "default", + * }, + * f: { + * h: 1, + * i: "example", + * }, + * optional_func: EXAMPLE_DEFAULTS.optional_func, + * required_func: options.required_func, + * }); + * } + * + * + * @param options the options to merge with defaults + * @param defaults default values for the options object. + * @returns + */ export function mergeOptions< Options extends object, // const Defaults extends DefaultsForDeep, >( - options: Options, + options: OptionsObjectFilter, defaults: DefaultsForDeep, //Defaults, ): OptionsWithDefaultsDeep> { const result = { ...options } as any; for (const key in defaults) { if (result[key] == null) { result[key] = (defaults as any)[key]; + continue; + } + if ( + typeof result[key] === "object" && + typeof (defaults as any)[key] === "object" && + !(result[key] instanceof Function) + ) { + // recursively merge objects + result[key] = mergeOptions( + result[key] as OptionsObjectFilter, + (defaults as any)[key], + ) as any; } } return result as OptionsWithDefaultsDeep>;