import { Arr, expectType, Obj } from 'ts-data-forge'; import { type Intersection, type NonEmptyTuple, type UnknownRecord, } from 'ts-type-forge'; import { union } from '../compose/index.mjs'; import { literal } from '../other-types/index.mjs'; import { type ExcessPropertyOption, type RecordTypeInternals, type Type, type TypeOf, expandShapeStructure, hasRecordInternals, } from '../type.mjs'; import { toIntersectionString } from '../utils/index.mjs'; import { record } from './record.mjs'; const MERGE_RECORDS_MAX_VARIANTS = 10_000; export const mergeRecords = < const Types extends NonEmptyTuple>, >( recordTypes: Types, options?: Partial< Readonly<{ typeName: string; excessProperty: ExcessPropertyOption; }> >, ): MergeRecordsType => { if (!recordTypes.every(hasRecordInternals)) { throw new Error( 'Expected a record type but received a non-record type in mergeRecords', ); } const typeNameFilled: string = options?.typeName ?? `(${toIntersectionString(recordTypes.map((a) => a.typeName))})`; // Expand all shape structures to get all possible shape combinations const expandedShapesPerType = Arr.map(recordTypes, (t) => expandShapeStructure(t.shapeStructure), ); // Estimate the total number of merged variants that would be produced. // This is the product of the number of variants for each record type. const estimatedVariantCount = expandedShapesPerType.reduce( (acc, shapes) => acc * shapes.length, 1, ); if (estimatedVariantCount > MERGE_RECORDS_MAX_VARIANTS) { throw new Error( `mergeRecords would create ${estimatedVariantCount} record variants, exceeding the limit of ${MERGE_RECORDS_MAX_VARIANTS}. ` + 'This could lead to excessive memory or CPU usage. Consider simplifying the record types or reducing union/intersection nesting.', ); } // For merging records (which is an intersection operation), // we need to create the cartesian product of all variants // For example: {a} & ({b} | {c}) = ({a} & {b}) | ({a} & {c}) const allCombinations = Arr.cartesianProduct(expandedShapesPerType); // Merge each combination const mergedShapes = Arr.map(allCombinations, (shapes) => Obj.merge(...shapes), ); const excessProperty = options?.excessProperty ?? deriveStrictestExcessProperty(recordTypes); // If there's only one merged shape, return it directly if (Arr.isFixedLengthTuple(mergedShapes, 1)) { // eslint-disable-next-line total-functions/no-unsafe-type-assertion return record(mergedShapes[0], { typeName: typeNameFilled, excessProperty, }) as MergeRecordsType; } // If there are multiple variants, we need to return a union const variants = Arr.map(mergedShapes, (shape) => record(shape, { excessProperty }), ); // eslint-disable-next-line total-functions/no-unsafe-type-assertion return union(variants as NonEmptyTuple>, { typeName: typeNameFilled, }) as MergeRecordsType; }; type MergeRecordsType[]> = Type< MergedExactValue >; /** Compute the merged value type directly from input types' `defaultValue`. */ type MergedExactValue[]> = FlattenIntersection>>; /** Flatten an intersection result into a single mapped type for better TypeScript compatibility. */ type FlattenIntersection = T extends UnknownRecord ? Readonly<{ [K in keyof T]: T[K] }> : T; type ExactValueTuple = Types extends readonly [infer Head, ...infer Tail] ? readonly [ExactValueOf, ...ExactValueTuple] : readonly []; type ExactValueOf = T extends Readonly<{ defaultValue: infer V }> ? V : never; const deriveStrictestExcessProperty = ( types: readonly RecordTypeInternals[], ): ExcessPropertyOption => types.some((t) => t.excessProperty === 'reject') ? 'reject' : 'allow'; // Verify MergedExactValue flattens correctly { type R1 = ReturnType< typeof record; y: Type }>> >; type R2 = ReturnType< typeof record; w: Type }>> >; expectType< MergedExactValue, Readonly<{ x: number; y: number; z: number; w: number }> >('='); } expectType< TypeOf< Type< Readonly<{ a: 0; b: 0; }> > >, Readonly<{ a: 0; b: 0; }> >('='); expectType< Intersection< readonly [ Readonly<{ a: 0; b: 0; }>, Readonly<{ b: 0; c: 0; }>, ] >, Readonly<{ a: 0; b: 0; c: 0; }> >('='); expectType< Intersection< readonly [ Readonly<{ a: 0; b: 0; }>, Readonly<{ b: 0; c: 0; }>, Readonly<{ c: 0; d: 0; }>, ] >, Readonly<{ a: 0; b: 0; c: 0; d: 0; }> >('='); expectType< Intersection< readonly [ Readonly<{ a: 0; b: 0; }>, Readonly<{ b: 1; c: 0; }>, ] >, never >('='); if (import.meta.vitest !== undefined) { test('Obj.merge for shapes', () => { const _m1 = Obj.merge( { a: literal(0), b: literal(0) }, { c: literal(0), d: literal(0) }, ); expectType< typeof _m1, Readonly<{ a: Type<0>; b: Type<0>; c: Type<0>; d: Type<0>; }> >('='); const _m2 = Obj.merge( { a: literal(0), b: literal(0) }, { b: literal(0), c: literal(0) }, ); expectType< typeof _m2, Readonly<{ a: Type<0>; b: Type<0>; c: Type<0>; }> >('='); assert.isTrue(true); // dummy assertion to avoid "Test has no assertions" error }); }