{"version":3,"file":"union.mjs","sources":["../../src/compose/union.mts"],"sourcesContent":["import { Arr, expectType, memoizeFunction, Result } from 'ts-data-forge';\nimport { type ArrayElement, type NonEmptyTuple } from 'ts-type-forge';\nimport {\n  flattenShapeStructure,\n  hasRecordInternals,\n  hasTupleInternals,\n  hasUnionInternals,\n  type AnyType,\n  type ExcessPropertyOption,\n  type Type,\n  type TypeOf,\n  type UnionTypeInternals,\n} from '../type.mjs';\nimport {\n  createAssertFn,\n  createCastFn,\n  createIsFn,\n  runtimeTypeNameOf,\n  toUnionString,\n  UNION_MEMBER_LISTING_MAX_LENGTH,\n  type ValidationError,\n} from '../utils/index.mjs';\n\nexport const union = <const Types extends NonEmptyTuple<AnyType>>(\n  types: Types,\n  options?: Partial<\n    Readonly<{\n      typeName: string;\n      defaultType: UnionType<Types>;\n    }>\n  >,\n): UnionType<Types> => {\n  type T = UnionTypeValue<Types>;\n\n  const getDefaultType = memoizeFunction(\n    (): UnionType<Types> =>\n      options?.defaultType ?? (types[0] as UnionType<Types>),\n  );\n\n  const typeNameFilled: string =\n    options?.typeName ?? `(${toUnionString(types.map((a) => a.typeName))})`;\n\n  /**\n   * A union nested directly inside this one contributes its own members\n   * instead of itself. `A | (B | C)` and `A | B | C` describe the same set of\n   * values, so this changes nothing about what the union accepts.\n   *\n   * It was introduced for the error path. Reporting the member closest to a\n   * rejected value starts by filing each member under a single runtime type\n   * category, taken from its default value — and a nested union has only one\n   * default value. Unflattened, `union([union([literal('alpha'), literal(42)]),\n   * ...])` is filed under `string`, so validating `41` finds no candidate at\n   * all and the union falls back to listing the categories it accepts.\n   * Flattened, the `42` member competes on its own and is reported.\n   *\n   * Two things deliberately stay unflattened: `typeName` and the member\n   * listing use the members as they were written, so a nested union that\n   * carries a name of its own keeps it; and a `recursion` member answers\n   * {@link hasUnionInternals} without running its definition, so a type\n   * defined in terms of itself stays a single opaque member.\n   *\n   * See `documents/union-closest-member-heuristic.md`.\n   */\n  const memberTypes: readonly AnyType[] = types.flatMap((t) =>\n    hasUnionInternals(t) ? t.memberTypes : [t],\n  );\n\n  const validate: Type<T>['validate'] = (a) => {\n    if (memberTypes.some((t) => t.is(a))) {\n      // eslint-disable-next-line total-functions/no-unsafe-type-assertion\n      return Result.ok(a as T);\n    }\n\n    const memberTypeNames = types.map((t) => t.typeName);\n\n    if (\n      toUnionString(memberTypeNames).length <= UNION_MEMBER_LISTING_MAX_LENGTH\n    ) {\n      return Result.err([\n        {\n          path: [],\n          actualValue: a,\n          expectedType: typeNameFilled,\n          typeName: typeNameFilled,\n          details: {\n            kind: 'union',\n            typeNames: memberTypeNames,\n          },\n        } satisfies ValidationError,\n      ]);\n    }\n\n    // The member listing is too long to be a readable error message, so\n    // narrow the report down to the members whose runtime type category\n    // (derived from each member's default value) matches the value.\n    const actualCategory = runtimeTypeNameOf(a);\n\n    const candidates = memberTypes\n      .filter((t) => runtimeTypeNameOf(t.defaultValue) === actualCategory)\n      .map((t) => evaluateMember(t, a));\n\n    if (!Arr.isNonEmpty(candidates)) {\n      return Result.err([\n        {\n          path: [],\n          actualValue: a,\n          expectedType: typeNameFilled,\n          typeName: typeNameFilled,\n          details: {\n            kind: 'union-category-mismatch',\n            memberCount: memberTypes.length,\n            memberCategories: Arr.uniq(\n              memberTypes.map((t) => runtimeTypeNameOf(t.defaultValue)),\n            ),\n          },\n        } satisfies ValidationError,\n      ]);\n    }\n\n    // The closest member is the one with the lowest similarity score (see\n    // `evaluateMember`); its errors explain the mismatch far better than the\n    // full member listing would. Members that tie with it are named alongside\n    // it, and the first of them (in declaration order, as in `prune` and\n    // `fill`) is the one whose errors are reported.\n    const closest = Arr.minBy(\n      candidates,\n      (candidate) => candidate,\n      compareByCloseness,\n    ).value;\n\n    const equallyClose = candidates.filter(\n      (c) => c !== closest && compareByCloseness(c, closest) === 0,\n    );\n\n    return Result.err(\n      Arr.toUnshifted({\n        path: [],\n        actualValue: a,\n        expectedType: typeNameFilled,\n        typeName: typeNameFilled,\n        details: {\n          kind: 'union-closest-member',\n          memberCount: memberTypes.length,\n          closestMemberTypeName: closest.typeName,\n          equallyCloseMemberTypeNames: equallyClose.map((c) => c.typeName),\n        },\n      } satisfies ValidationError)(closest.errors),\n    );\n  };\n\n  const is = createIsFn<T>(validate);\n\n  const fill: Type<T>['fill'] = (a) => (is(a) ? a : getDefaultType().fill(a));\n\n  // Prunes with the first member type that matches the value.\n  const prune = (a: T): T => {\n    const matched = memberTypes.find((t) => t.is(a));\n\n    return matched === undefined\n      ? a\n      : // eslint-disable-next-line total-functions/no-unsafe-type-assertion\n        (matched.prune(a) as T);\n  };\n\n  const baseType = {\n    typeName: typeNameFilled,\n    get defaultValue() {\n      return getDefaultType().defaultValue;\n    },\n    fill,\n    prune,\n    validate,\n    is,\n    assertIs: createAssertFn(validate),\n    cast: createCastFn(validate),\n    // Lets a union that takes this one as a member flatten it in.\n    memberTypes,\n  } as const satisfies Type<T> & UnionTypeInternals;\n\n  // If all types are records, add RecordTypeInternals with union structure\n  if (types.every(hasRecordInternals)) {\n    const shapeStructures = Arr.map(types, (t) => t.shapeStructure);\n\n    const excessProperty: ExcessPropertyOption = Arr.some(\n      types,\n      (t) => t.excessProperty === 'reject',\n    )\n      ? 'reject'\n      : 'allow';\n\n    // eslint-disable-next-line total-functions/no-unsafe-type-assertion\n    return {\n      ...baseType,\n      shapeStructure: Arr.isFixedLengthTuple(shapeStructures, 1)\n        ? shapeStructures[0]\n        : ({ kind: 'union', variants: shapeStructures } as const),\n      excessProperty,\n    } as UnionType<Types>;\n  }\n\n  return baseType as UnionType<Types>;\n};\n\n/**\n * How close a member is to a value it rejected.\n *\n * `score` is the number of checks the member failed minus the number it\n * passed, so a member is credited for what the value does satisfy. Plain error\n * counting would let a small unrelated member (one missing key) outrank a large\n * member the value nearly satisfies (two wrong fields out of ten), while a\n * ratio would do the opposite and let a large member win with a long list of\n * errors just because it also has many keys. Subtracting keeps both the failed\n * and the passed checks in absolute terms.\n *\n * Members with the same score are ordered by how much they satisfied, so the\n * one that recognized more of the value comes first.\n *\n * See `documents/union-closest-member-heuristic.md` for the scenarios these\n * rules were chosen against.\n */\ntype MemberCloseness = Readonly<{\n  typeName: string;\n  errors: readonly ValidationError[];\n  satisfiedCheckCount: number;\n  score: number;\n}>;\n\nconst evaluateMember = (memberType: AnyType, a: unknown): MemberCloseness => {\n  const errors = validationErrorsOf(memberType.validate(a));\n\n  // A member cannot pass fewer than zero checks: a record that rejects excess\n  // properties can report more errors than it has keys.\n  const satisfiedCheckCount = Math.max(\n    0,\n    checkCountOf(memberType) - errors.length,\n  );\n\n  return {\n    typeName: memberType.typeName,\n    errors,\n    satisfiedCheckCount,\n    score: errors.length - satisfiedCheckCount,\n  };\n};\n\nconst compareByCloseness = (x: MemberCloseness, y: MemberCloseness): number =>\n  x.score !== y.score\n    ? x.score - y.score\n    : y.satisfiedCheckCount - x.satisfiedCheckCount;\n\n/**\n * How many checks a member performs on a value: one per key for a record, one\n * per element for a tuple, and a single check for everything else, which either\n * matches or does not. A record whose shape cannot be flattened to a single set\n * of keys (a union of shapes) also counts as one check.\n */\nconst checkCountOf = (memberType: AnyType): number => {\n  if (hasRecordInternals(memberType)) {\n    const shape = flattenShapeStructure(memberType.shapeStructure);\n\n    return shape === undefined ? 1 : Object.keys(shape).length;\n  }\n\n  if (hasTupleInternals(memberType)) {\n    return memberType.elementTypes.length;\n  }\n\n  return 1;\n};\n\nconst validationErrorsOf = (\n  result: Result<unknown, readonly ValidationError[]>,\n): readonly ValidationError[] =>\n  Result.isErr(result) ? result.value : ([] as const);\n\ntype UnionType<Types extends NonEmptyTuple<AnyType>> = Type<\n  UnionTypeValue<Types>\n>;\n\ntype UnionTypeValue<Types extends NonEmptyTuple<AnyType>> =\n  TsFortressInternal.UnionTypeValueImpl<Types>;\n\nnamespace TsFortressInternal {\n  export type UnionTypeValueImpl<Types extends NonEmptyTuple<AnyType>> =\n    UnwrapUnion<ArrayElement<Types>>;\n\n  type UnwrapUnion<T extends AnyType> = T extends T ? TypeOf<T> : never;\n}\n\nexpectType<\n  UnionType<\n    readonly [Type<Readonly<{ a: 0; b: 0 }>>, Type<Readonly<{ b: 0; c: 0 }>>]\n  >,\n  Type<Readonly<{ a: 0; b: 0 } | { b: 0; c: 0 }>>\n>('=');\n\nexpectType<\n  UnionType<\n    readonly [\n      Type<Readonly<{ a: 0; b: 0 }>>,\n      Type<Readonly<{ b: 0; c: 0 }>>,\n      Type<Readonly<{ e: 0; f: 0 }>>,\n    ]\n  >,\n  Type<Readonly<{ a: 0; b: 0 } | { b: 0; c: 0 } | { e: 0; f: 0 }>>\n>('=');\n"],"names":[],"mappings":"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"}