diff --git a/.changeset/eighty-ties-drum.md b/.changeset/eighty-ties-drum.md new file mode 100644 index 0000000000..6026a85b1e --- /dev/null +++ b/.changeset/eighty-ties-drum.md @@ -0,0 +1,5 @@ +--- +"@arktype/util": patch +--- + +### Add a new `arrayEquals` util for shallow array comparisons diff --git a/.changeset/holy-moly-guacamole.md b/.changeset/holy-moly-guacamole.md new file mode 100644 index 0000000000..9b708f56d3 --- /dev/null +++ b/.changeset/holy-moly-guacamole.md @@ -0,0 +1,5 @@ +--- +"@arktype/schema": patch +--- + +### Improve discrimination logic across several issues (see primary release notes at [ArkType's CHANGELOG](../type/CHANGELOG.md)) diff --git a/.changeset/thin-boxes-film.md b/.changeset/thin-boxes-film.md new file mode 100644 index 0000000000..a66908d044 --- /dev/null +++ b/.changeset/thin-boxes-film.md @@ -0,0 +1,71 @@ +--- +"@arktype/attest": minor +--- + +### Throw by default when attest.instantiations() exceeds the specified benchPercentThreshold + +Tests like this will now correctly throw inline instead of return a non-zero exit code: + +```ts +it("can snap instantiations", () => { + type Z = makeComplexType<"asbsdfsaodisfhsda"> + // will throw here as the actual number of instantiations is more + // than 20% higher than the snapshotted value + attest.instantiations([1, "instantiations"]) +}) +``` + +### Snapshotted completions will now be alphabetized + +This will help improve stability, especially for large completion lists like this one which we updated more times than we'd care to admit šŸ˜… + +```ts +attest(() => type([""])).completions({ + "": [ + "...", + "===", + "Array", + "Date", + "Error", + "Function", + "Map", + "Promise", + "Record", + "RegExp", + "Set", + "WeakMap", + "WeakSet", + "alpha", + "alphanumeric", + "any", + "bigint", + "boolean", + "creditCard", + "digits", + "email", + "false", + "format", + "instanceof", + "integer", + "ip", + "keyof", + "lowercase", + "never", + "null", + "number", + "object", + "parse", + "semver", + "string", + "symbol", + "this", + "true", + "undefined", + "unknown", + "uppercase", + "url", + "uuid", + "void" + ] +}) +``` diff --git a/ark/attest/__tests__/completions.test.ts b/ark/attest/__tests__/completions.test.ts index 17eb607425..22233fd715 100644 --- a/ark/attest/__tests__/completions.test.ts +++ b/ark/attest/__tests__/completions.test.ts @@ -31,7 +31,7 @@ contextualize(() => { it(".type.completions", () => { //@ts-expect-error attest({ ark: "s" } as Arks).type.completions({ - s: ["string", "symbol", "semver"] + s: ["semver", "string", "symbol"] }) }) diff --git a/ark/attest/__tests__/demo.test.ts b/ark/attest/__tests__/demo.test.ts index bd718e701b..d77fd7d40f 100644 --- a/ark/attest/__tests__/demo.test.ts +++ b/ark/attest/__tests__/demo.test.ts @@ -66,7 +66,7 @@ contextualize(() => { // snapshot expected completions for any string literal! // @ts-expect-error (if your expression would throw, prepend () =>) attest(() => type({ a: "a", b: "b" })).completions({ - a: ["any", "alpha", "alphanumeric"], + a: ["alpha", "alphanumeric", "any"], b: ["bigint", "boolean"] }) type Legends = { faker?: "šŸ"; [others: string]: unknown } diff --git a/ark/attest/__tests__/instantiations.test.ts b/ark/attest/__tests__/instantiations.test.ts index aefe232201..c9cbff2187 100644 --- a/ark/attest/__tests__/instantiations.test.ts +++ b/ark/attest/__tests__/instantiations.test.ts @@ -3,7 +3,7 @@ import { type } from "arktype" import { it } from "mocha" contextualize(() => { - it("Inline instantiations", () => { + it("inline", () => { const user = type({ kind: "'admin'", "powers?": "string[]" @@ -17,4 +17,12 @@ contextualize(() => { }) attest.instantiations([7574, "instantiations"]) }) + it("fails on instantiations above threshold", () => { + attest(() => { + const user = type({ + foo: "0|1|2|3|4|5|6" + }) + attest.instantiations([1, "instantiations"]) + }).throws("exceeded baseline by") + }) }) diff --git a/ark/attest/__tests__/snapExpectedOutput.ts b/ark/attest/__tests__/snapExpectedOutput.ts index d661034701..b56027aa17 100644 --- a/ark/attest/__tests__/snapExpectedOutput.ts +++ b/ark/attest/__tests__/snapExpectedOutput.ts @@ -1,4 +1,5 @@ import { attest, cleanup, setup } from "@arktype/attest" +import type { makeComplexType } from "./utils.js" setup() @@ -23,4 +24,11 @@ multiline`) attest("with `quotes`").snap("with `quotes`") +const it = (name: string, fn: () => void) => fn() + +it("can snap instantiations", () => { + type Z = makeComplexType<"asbsdfsaodisfhsda"> + attest.instantiations([229, "instantiations"]) +}) + cleanup() diff --git a/ark/attest/__tests__/snapTemplate.ts b/ark/attest/__tests__/snapTemplate.ts index c2d584b236..a98659c2a2 100644 --- a/ark/attest/__tests__/snapTemplate.ts +++ b/ark/attest/__tests__/snapTemplate.ts @@ -1,4 +1,5 @@ import { attest, cleanup, setup } from "@arktype/attest" +import type { makeComplexType } from "./utils.js" setup() @@ -22,4 +23,11 @@ attest("multiline\nmultiline").snap() attest("with `quotes`").snap() +const it = (name: string, fn: () => void) => fn() + +it("can snap instantiations", () => { + type Z = makeComplexType<"asbsdfsaodisfhsda"> + attest.instantiations() +}) + cleanup() diff --git a/ark/attest/bench/baseline.ts b/ark/attest/bench/baseline.ts index cac8d8c2d6..8d4f19817c 100644 --- a/ark/attest/bench/baseline.ts +++ b/ark/attest/bench/baseline.ts @@ -1,10 +1,11 @@ import { snapshot } from "@arktype/util" +import { AssertionError } from "node:assert" import process from "node:process" import { queueSnapshotUpdate, writeSnapshotUpdatesOnExit } from "../cache/snapshots.js" -import type { BenchAssertionContext, BenchContext } from "./bench.js" +import type { BenchContext } from "./bench.js" import { stringifyMeasure, type MarkMeasure, @@ -15,7 +16,7 @@ import { export const queueBaselineUpdateIfNeeded = ( updated: Measure | MarkMeasure, baseline: Measure | MarkMeasure | undefined, - ctx: BenchAssertionContext + ctx: BenchContext ): void => { // If we already have a baseline and the user didn't pass an update flag, do nothing if (baseline && !ctx.cfg.updateSnapshots) return @@ -61,11 +62,16 @@ const handlePositiveDelta = (formattedDelta: string, ctx: BenchContext) => { const message = `'${ctx.qualifiedName}' exceeded baseline by ${formattedDelta} (threshold is ${ctx.cfg.benchPercentThreshold}%).` console.error(`šŸ“ˆ ${message}`) if (ctx.cfg.benchErrorOnThresholdExceeded) { - process.exitCode = 1 - // Summarize failures at the end of output - process.on("exit", () => { - console.error(`āŒ ${message}`) - }) + const errorSummary = `āŒ ${message}` + if (ctx.kind === "instantiations") + throw new AssertionError({ message: errorSummary }) + else { + process.exitCode = 1 + // Summarize failures at the end of output + process.on("exit", () => { + console.error(errorSummary) + }) + } } } diff --git a/ark/attest/bench/bench.ts b/ark/attest/bench/bench.ts index b994c2d2f2..4e943e3af9 100644 --- a/ark/attest/bench/bench.ts +++ b/ark/attest/bench/bench.ts @@ -307,9 +307,6 @@ export type BenchContext = { benchCallPosition: SourcePosition lastSnapCallPosition: SourcePosition | undefined isAsync: boolean -} - -export type BenchAssertionContext = BenchContext & { kind: TimeAssertionName | "types" | "instantiations" } diff --git a/ark/attest/bench/type.ts b/ark/attest/bench/type.ts index a1ecca31cb..a99fc756f9 100644 --- a/ark/attest/bench/type.ts +++ b/ark/attest/bench/type.ts @@ -15,7 +15,7 @@ import { import type { TypeRelationship } from "../cache/writeAssertionCache.js" import { getConfig } from "../config.js" import { compareToBaseline, queueBaselineUpdateIfNeeded } from "./baseline.js" -import type { BenchAssertionContext, BenchContext } from "./bench.js" +import type { BenchContext } from "./bench.js" import { createTypeComparison, type Measure, @@ -75,7 +75,7 @@ export type ArgAssertionData = { } export const instantiationDataHandler = ( - ctx: BenchAssertionContext, + ctx: BenchContext, args?: Measure, isBenchFunction = true ): void => { diff --git a/ark/attest/cache/writeAssertionCache.ts b/ark/attest/cache/writeAssertionCache.ts index b6d301b657..221d2572ae 100644 --- a/ark/attest/cache/writeAssertionCache.ts +++ b/ark/attest/cache/writeAssertionCache.ts @@ -128,7 +128,7 @@ const getCompletions = (attestCall: ts.CallExpression) => { } return flatMorph(completions, (prefix, entries) => - entries.length >= 1 ? [prefix, entries] : [] + entries.length >= 1 ? [prefix, entries.sort()] : [] ) } diff --git a/ark/attest/config.ts b/ark/attest/config.ts index 6164d484be..854378a254 100644 --- a/ark/attest/config.ts +++ b/ark/attest/config.ts @@ -53,9 +53,9 @@ export const getDefaultAttestConfig = (): BaseAttestConfig => ({ skipInlineInstantiations: false, tsVersions: "typescript", benchPercentThreshold: 20, - benchErrorOnThresholdExceeded: false, + benchErrorOnThresholdExceeded: true, filter: undefined, - testDeclarationAliases: ["bench", "it"], + testDeclarationAliases: ["bench", "it", "test"], formatter: `npm exec --no -- prettier --write`, shouldFormat: true }) diff --git a/ark/schema/__tests__/union.test.ts b/ark/schema/__tests__/union.test.ts index f14b2091df..3bda2f8c41 100644 --- a/ark/schema/__tests__/union.test.ts +++ b/ark/schema/__tests__/union.test.ts @@ -1,5 +1,9 @@ import { attest, contextualize } from "@arktype/attest" -import { schema, validation } from "@arktype/schema" +import { + schema, + validation, + writeOrderedIntersectionMessage +} from "@arktype/schema" contextualize(() => { it("binary", () => { @@ -68,4 +72,29 @@ contextualize(() => { const result = l.and(r) attest(result.json).equals(l.json) }) + + it("unordered union with ordered union", () => { + const l = schema({ + branches: ["string", "number"], + ordered: true + }) + const r = schema(["number", "string"]) + const result = l.and(r) + attest(result.json).equals(l.json) + }) + + it("intersection of ordered unions", () => { + const l = schema({ + branches: ["string", "number"], + ordered: true + }) + const r = schema({ + branches: ["number", "string"], + ordered: true + }) + + attest(() => l.and(r)).throws( + writeOrderedIntersectionMessage("string | number", "number | string") + ) + }) }) diff --git a/ark/schema/index.ts b/ark/schema/index.ts index dadb472367..9fc2d7224b 100644 --- a/ark/schema/index.ts +++ b/ark/schema/index.ts @@ -38,7 +38,7 @@ export * from "./shared/errors.js" export * from "./shared/implement.js" export * from "./shared/intersections.js" export * from "./shared/utils.js" -export * from "./structure/index.js" +export * from "./structure/indexed.js" export * from "./structure/optional.js" export * from "./structure/prop.js" export * from "./structure/sequence.js" diff --git a/ark/schema/kinds.ts b/ark/schema/kinds.ts index e98542693f..d1045f0df5 100644 --- a/ark/schema/kinds.ts +++ b/ark/schema/kinds.ts @@ -68,7 +68,7 @@ import { IndexNode, indexImplementation, type IndexDeclaration -} from "./structure/index.js" +} from "./structure/indexed.js" import { OptionalNode, optionalImplementation, diff --git a/ark/schema/refinements/before.ts b/ark/schema/refinements/before.ts index cf36344811..606748fe94 100644 --- a/ark/schema/refinements/before.ts +++ b/ark/schema/refinements/before.ts @@ -69,7 +69,7 @@ export const beforeImplementation: nodeImplementationOf = before.overlapIsUnit(after) ? ctx.$.node("unit", { unit: before.rule }) : null - : Disjoint.from("range", before, after) + : Disjoint.init("range", before, after) } }) diff --git a/ark/schema/refinements/exactLength.ts b/ark/schema/refinements/exactLength.ts index bc7c2cb615..9ea9c97bbd 100644 --- a/ark/schema/refinements/exactLength.ts +++ b/ark/schema/refinements/exactLength.ts @@ -42,14 +42,12 @@ export const exactLengthImplementation: nodeImplementationOf - new Disjoint({ - '["length"]': { - unit: { - l: ctx.$.node("unit", { unit: l.rule }), - r: ctx.$.node("unit", { unit: r.rule }) - } - } - }), + Disjoint.init( + "unit", + ctx.$.node("unit", { unit: l.rule }), + ctx.$.node("unit", { unit: r.rule }), + { path: ["length"] } + ), minLength: (exactLength, minLength) => ( minLength.exclusive ? @@ -57,7 +55,7 @@ export const exactLengthImplementation: nodeImplementationOf= minLength.rule ) ? exactLength - : Disjoint.from("range", exactLength, minLength), + : Disjoint.init("range", exactLength, minLength), maxLength: (exactLength, maxLength) => ( maxLength.exclusive ? @@ -65,7 +63,7 @@ export const exactLengthImplementation: nodeImplementationOf = max.overlapIsUnit(min) ? ctx.$.node("unit", { unit: max.rule }) : null - : Disjoint.from("range", max, min) + : Disjoint.init("range", max, min) } }) diff --git a/ark/schema/refinements/maxLength.ts b/ark/schema/refinements/maxLength.ts index 4a2c509dd9..d3d02c1c97 100644 --- a/ark/schema/refinements/maxLength.ts +++ b/ark/schema/refinements/maxLength.ts @@ -8,9 +8,9 @@ import { import type { TraverseAllows } from "../shared/traversal.js" import { BaseRange, + parseExclusiveKey, type BaseRangeInner, type LengthBoundableData, - parseExclusiveKey, type UnknownNormalizedRangeSchema } from "./range.js" @@ -60,7 +60,7 @@ export const maxLengthImplementation: nodeImplementationOf max.overlapIsUnit(min) ? ctx.$.node("exactLength", { rule: max.rule }) : null - : Disjoint.from("range", max, min) + : Disjoint.init("range", max, min) } }) diff --git a/ark/schema/roots/domain.ts b/ark/schema/roots/domain.ts index e49d1b32d0..03a4db1821 100644 --- a/ark/schema/roots/domain.ts +++ b/ark/schema/roots/domain.ts @@ -71,6 +71,6 @@ export const domainImplementation: nodeImplementationOf = actual: data => (typeof data === "boolean" ? `${data}` : domainOf(data)) }, intersections: { - domain: (l, r) => Disjoint.from("domain", l, r) + domain: (l, r) => Disjoint.init("domain", l, r) } }) diff --git a/ark/schema/roots/intersection.ts b/ark/schema/roots/intersection.ts index ac4cdc790b..29ebe47cd0 100644 --- a/ark/schema/roots/intersection.ts +++ b/ark/schema/roots/intersection.ts @@ -102,7 +102,7 @@ export class IntersectionNode extends BaseRoot { expression: string = this.structure?.expression || - this.children.map(node => node.nestableExpression).join(" & ") || + `${this.basis ? this.basis.nestableExpression + " " : ""}${this.refinements.join(" & ")}` || "unknown" get shortDescription(): string { diff --git a/ark/schema/roots/morph.ts b/ark/schema/roots/morph.ts index 18a680f1bb..cbc73e5572 100644 --- a/ark/schema/roots/morph.ts +++ b/ark/schema/roots/morph.ts @@ -96,9 +96,11 @@ export const morphImplementation: nodeImplementationOf = }, intersections: { morph: (l, r, ctx) => { - if (l.morphs.some((morph, i) => morph !== r.morphs[i])) - // TODO: check in for union reduction - return throwParseError("Invalid intersection of morphs") + if (l.morphs.some((morph, i) => morph !== r.morphs[i])) { + return throwParseError( + writeMorphIntersectionMessage(l.expression, r.expression) + ) + } const inTersection = intersectNodes(l.in, r.in, ctx) if (inTersection instanceof Disjoint) return inTersection @@ -181,6 +183,14 @@ export class MorphNode extends BaseRoot { } } +export const writeMorphIntersectionMessage = ( + lDescription: string, + rDescription: string +) => + `The intersection of distinct morphs at a single path is indeterminate: +Left: ${lDescription} +Right: ${rDescription}` + export type inferPipes = pipes extends [infer head extends Morph, ...infer tail extends Morph[]] ? inferPipes< diff --git a/ark/schema/roots/proto.ts b/ark/schema/roots/proto.ts index 0df2b9a2d4..82f053c20f 100644 --- a/ark/schema/roots/proto.ts +++ b/ark/schema/roots/proto.ts @@ -78,11 +78,11 @@ export const protoImplementation: nodeImplementationOf = proto: (l, r) => constructorExtends(l.proto, r.proto) ? l : constructorExtends(r.proto, l.proto) ? r - : Disjoint.from("proto", l, r), + : Disjoint.init("proto", l, r), domain: (proto, domain, ctx) => domain.domain === "object" ? proto - : Disjoint.from( + : Disjoint.init( "domain", ctx.$.keywords.object.raw as DomainNode, domain diff --git a/ark/schema/roots/union.ts b/ark/schema/roots/union.ts index f948e17085..326e840f9e 100644 --- a/ark/schema/roots/union.ts +++ b/ark/schema/roots/union.ts @@ -1,17 +1,21 @@ import { appendUnique, + arrayEquals, cached, compileLiteralPropAccess, + compileSerializedValue, domainDescriptions, - entriesOf, flatMorph, groupBy, isArray, isKeyOf, printable, + registeredReference, throwInternalError, + throwParseError, type Domain, type Json, + type Key, type SerializedPrimitive, type array, type keySet, @@ -20,7 +24,7 @@ import { import type { Node, NodeSchema } from "../kinds.js" import type { NodeCompiler } from "../shared/compile.js" import type { BaseMeta, declareNode } from "../shared/declare.js" -import { Disjoint, type SerializedPath } from "../shared/disjoint.js" +import { Disjoint } from "../shared/disjoint.js" import type { ArkError } from "../shared/errors.js" import { implementNode, @@ -151,11 +155,15 @@ export const unionImplementation: nodeImplementationOf = union: (l, r, ctx) => { if (l.isNever !== r.isNever) { // if exactly one operand is never, we can use it to discriminate based on presence - return Disjoint.from("presence", l, r) + return Disjoint.init("presence", l, r) } let resultBranches: readonly UnionChildNode[] | Disjoint if (l.ordered) { - if (r.ordered) return Disjoint.from("indiscriminableMorphs", l, r) + if (r.ordered) { + throwParseError( + writeOrderedIntersectionMessage(l.expression, r.expression) + ) + } resultBranches = intersectBranches(r.branches, l.branches, ctx) if (resultBranches instanceof Disjoint) resultBranches.invert() @@ -198,10 +206,9 @@ export class UnionNode extends BaseRoot { discriminantJson = this.discriminant ? discriminantToJson(this.discriminant) : null - expression: string = - this.isNever ? "never" - : this.isBoolean ? "boolean" - : this.branches.map(branch => branch.nestableExpression).join(" | ") + expression: string = expressBranches( + this.branches.map(n => n.nestableExpression) + ) get shortDescription(): string { return describeBranches( @@ -235,8 +242,8 @@ export class UnionNode extends BaseRoot { return this.compileIndiscriminable(js) // we need to access the path as optional so we don't throw if it isn't present - const condition = this.discriminant.path.reduce( - (acc, segment) => acc + compileLiteralPropAccess(segment, true), + const condition = this.discriminant.path.reduce( + (acc, k) => acc + compileLiteralPropAccess(k, true), this.discriminant.kind === "domain" ? "typeof data" : "data" ) @@ -265,10 +272,14 @@ export class UnionNode extends BaseRoot { : caseKeys ) + const serializedPathSegments = this.discriminant.path.map(k => + typeof k === "string" ? JSON.stringify(k) : registeredReference(k) + ) + js.line(`ctx.error({ expected: ${JSON.stringify(expected)}, actual: ${condition}, - relativePath: ${JSON.stringify(this.discriminant.path)} + relativePath: [${serializedPathSegments}] })`) } @@ -321,7 +332,7 @@ export class UnionNode extends BaseRoot { cases } } - const casesBySpecifier: CasesBySpecifier = {} + const candidates: DiscriminantCandidate[] = [] for (let lIndex = 0; lIndex < this.branches.length - 1; lIndex++) { const l = this.branches[lIndex] for (let rIndex = lIndex + 1; rIndex < this.branches.length; rIndex++) { @@ -329,56 +340,63 @@ export class UnionNode extends BaseRoot { const result = intersectNodesRoot(l.in, r.in, l.$) if (!(result instanceof Disjoint)) continue - for (const { path, kind, disjoint } of result.flat) { - if (!isKeyOf(kind, discriminantKinds)) continue + for (const entry of result) { + if (!isKeyOf(entry.kind, discriminantKinds) || entry.optional) + continue - const qualifiedDiscriminant: DiscriminantKey = `${path}${kind}` let lSerialized: string let rSerialized: string - if (kind === "domain") { - lSerialized = `"${(disjoint.l as DomainNode).domain}"` - rSerialized = `"${(disjoint.r as DomainNode).domain}"` - } else if (kind === "unit") { - lSerialized = (disjoint.l as UnitNode).serializedValue as never - rSerialized = (disjoint.r as UnitNode).serializedValue as never + if (entry.kind === "domain") { + lSerialized = `"${(entry.l as DomainNode).domain}"` + rSerialized = `"${(entry.r as DomainNode).domain}"` + } else if (entry.kind === "unit") { + lSerialized = (entry.l as UnitNode).serializedValue as never + rSerialized = (entry.r as UnitNode).serializedValue as never } else { return throwInternalError( - `Unexpected attempt to discriminate disjoint kind '${kind}'` + `Unexpected attempt to discriminate disjoint kind '${entry.kind}'` ) } - if (!casesBySpecifier[qualifiedDiscriminant]) { - casesBySpecifier[qualifiedDiscriminant] = { - [lSerialized]: [l], - [rSerialized]: [r] - } + const matching = candidates.find( + d => arrayEquals(d.path, entry.path) && d.kind === entry.kind + ) + if (!matching) { + candidates.push({ + kind: entry.kind, + cases: { + [lSerialized]: [l], + [rSerialized]: [r] + }, + path: entry.path + }) continue } - const cases = casesBySpecifier[qualifiedDiscriminant]! - if (!isKeyOf(lSerialized, cases)) cases[lSerialized] = [l] - else if (!cases[lSerialized].includes(l)) cases[lSerialized].push(l) - if (!isKeyOf(rSerialized, cases)) cases[rSerialized] = [r] - else if (!cases[rSerialized].includes(r)) cases[rSerialized].push(r) + matching.cases[lSerialized] = appendUnique( + matching.cases[lSerialized], + l + ) + matching.cases[rSerialized] = appendUnique( + matching.cases[rSerialized], + r + ) } } } - const bestDiscriminantEntry = entriesOf(casesBySpecifier) - .sort((a, b) => Object.keys(a[1]).length - Object.keys(b[1]).length) + const best = candidates + .sort((l, r) => Object.keys(l.cases).length - Object.keys(r.cases).length) .at(-1) - if (!bestDiscriminantEntry) return null - - const [specifier, bestCases] = bestDiscriminantEntry - const [path, kind] = parseDiscriminantKey(specifier) + if (!best) return null let defaultBranches = [...this.branches] - const cases = flatMorph(bestCases, (k, caseBranches) => { + const cases = flatMorph(best.cases, (k, caseBranches) => { const prunedBranches: BaseRoot[] = [] defaultBranches = defaultBranches.filter(n => !caseBranches.includes(n)) for (const branch of caseBranches) { - const pruned = pruneDiscriminant(kind, path, branch) + const pruned = pruneDiscriminant(best.kind, best.path, branch) // if any branch of the union has no constraints (i.e. is unknown) // return it right away if (pruned === null) return [k, true as const] @@ -404,8 +422,8 @@ export class UnionNode extends BaseRoot { } return { - kind, - path, + kind: best.kind, + path: best.path, cases } } @@ -413,7 +431,9 @@ export class UnionNode extends BaseRoot { const discriminantToJson = (discriminant: Discriminant): Json => ({ kind: discriminant.kind, - path: discriminant.path, + path: discriminant.path.map(k => + typeof k === "string" ? k : compileSerializedValue(k) + ), cases: flatMorph(discriminant.cases, (k, node) => [ k, node === true ? node @@ -422,7 +442,26 @@ const discriminantToJson = (discriminant: Discriminant): Json => ({ ]) }) -const describeBranches = (descriptions: string[]) => { +type DescribeBranchesOptions = { + delimiter?: string + finalDelimiter?: string +} + +const describeExpressionOptions: DescribeBranchesOptions = { + delimiter: " | ", + finalDelimiter: " | " +} + +const expressBranches = (expressions: string[]) => + describeBranches(expressions, describeExpressionOptions) + +const describeBranches = ( + descriptions: string[], + opts?: DescribeBranchesOptions +) => { + const delimiter = opts?.delimiter ?? ", " + const finalDelimiter = opts?.finalDelimiter ?? " or " + if (descriptions.length === 0) return "never" if (descriptions.length === 1) return descriptions[0] @@ -440,12 +479,12 @@ const describeBranches = (descriptions: string[]) => { if (seen[descriptions[i]]) continue seen[descriptions[i]] = true description += descriptions[i] - if (i < descriptions.length - 2) description += ", " + if (i < descriptions.length - 2) description += delimiter } const lastDescription = descriptions.at(-1)! if (!seen[lastDescription]) - description += ` or ${descriptions[descriptions.length - 1]}` + description += `${finalDelimiter}${descriptions[descriptions.length - 1]}` return description } @@ -512,7 +551,7 @@ export const intersectBranches = ( (batch, i) => batch?.flatMap(branch => branch.branches) ?? r[i] ) return resultBranches.length === 0 ? - Disjoint.from("union", l, r) + Disjoint.init("union", l, r) : resultBranches } @@ -543,6 +582,25 @@ export const reduceBranches = ({ )! if (intersection instanceof Disjoint) continue + if (!ordered) { + if (branches[i].includesMorph) { + throwParseError( + writeIndiscriminableMorphMessage( + branches[i].expression, + branches[j].expression + ) + ) + } + if (branches[j].includesMorph) { + throwParseError( + writeIndiscriminableMorphMessage( + branches[j].expression, + branches[i].expression + ) + ) + } + } + if (intersection.equals(branches[i].in)) { // preserve ordered branches that are a subtype of a subsequent branch uniquenessByIndex[i] = !!ordered @@ -557,23 +615,27 @@ export type CaseKey = DiscriminantKind extends kind ? string : DiscriminantKinds[kind] | "default" export type Discriminant = { - path: string[] + path: Key[] kind: kind cases: DiscriminatedCases } +type DiscriminantCandidate = { + path: Key[] + kind: kind + cases: CandidateCases +} + +type CandidateCases = { + [caseKey in CaseKey]: BaseRoot[] +} + export type DiscriminatedCases< kind extends DiscriminantKind = DiscriminantKind > = { [caseKey in CaseKey]: BaseRoot | true } -type DiscriminantKey = `${SerializedPath}${DiscriminantKind}` - -type CasesBySpecifier = { - [k in DiscriminantKey]?: Record -} - export type DiscriminantKinds = { domain: Domain unit: SerializedPrimitive @@ -586,13 +648,6 @@ const discriminantKinds: keySet = { export type DiscriminantKind = show -const parseDiscriminantKey = (key: DiscriminantKey) => { - const lastPathIndex = key.lastIndexOf("]") - const parsedPath: string[] = JSON.parse(key.slice(0, lastPathIndex + 1)) - const parsedKind: DiscriminantKind = key.slice(lastPathIndex + 1) as never - return [parsedPath, parsedKind] as const -} - export const pruneDiscriminant = ( discriminantKind: DiscriminantKind, path: TraversalPath, @@ -628,10 +683,17 @@ export const pruneDiscriminant = ( } ) -// // TODO: if deeply includes morphs? -// const writeUndiscriminableMorphUnionMessage = ( -// path: path -// ) => -// `${ -// path === "/" ? "A" : `At ${path}, a` -// } union including one or more morphs must be discriminable` as const +export const writeIndiscriminableMorphMessage = ( + morphDescription: string, + overlappingDescription: string +) => + `An unordered union of a type including a morph and a type with overlapping input is indeterminate: +Morph Branch: ${morphDescription} +Overlapping Branch: ${overlappingDescription}` + +export const writeOrderedIntersectionMessage = ( + lDescription: string, + rDescription: string +) => `The intersection of two ordered unions is indeterminate: +Left: ${lDescription} +Right: ${rDescription}` diff --git a/ark/schema/roots/unit.ts b/ark/schema/roots/unit.ts index f176ab6500..83d9440527 100644 --- a/ark/schema/roots/unit.ts +++ b/ark/schema/roots/unit.ts @@ -54,10 +54,10 @@ export const unitImplementation: nodeImplementationOf = `${expected === actual ? `must be reference equal to ${expected} (serialized to the same value)` : `must be ${expected} (was ${actual})`}` }, intersections: { - unit: (l, r) => Disjoint.from("unit", l, r), + unit: (l, r) => Disjoint.init("unit", l, r), ...defineRightwardIntersections("unit", (l, r) => r.allows(l.unit) ? l : ( - Disjoint.from( + Disjoint.init( "assignability", l, r.hasKind("intersection") ? diff --git a/ark/schema/shared/disjoint.ts b/ark/schema/shared/disjoint.ts index 0b3525edc7..ea02fe1998 100644 --- a/ark/schema/shared/disjoint.ts +++ b/ark/schema/shared/disjoint.ts @@ -1,141 +1,77 @@ -import { - entriesOf, - flatMorph, - fromEntries, - isArray, - printable, - register, - throwInternalError, - throwParseError, - type entryOf -} from "@arktype/util" +import { isArray, throwParseError, type Key } from "@arktype/util" import type { Node } from "../kinds.js" import type { BaseNode } from "../node.js" import type { BaseRoot } from "../roots/root.js" +import type { PropKind } from "../structure/prop.js" import type { BoundKind } from "./implement.js" -import { hasArkKind } from "./utils.js" - -type DisjointKinds = { - domain?: { - l: Node<"domain"> - r: Node<"domain"> - } - unit?: { - l: Node<"unit"> - r: Node<"unit"> - } - proto?: { - l: Node<"proto"> - r: Node<"proto"> - } - presence?: { - l: BaseRoot - r: BaseRoot - } - range?: { - l: Node - r: Node - } - // exactly one of l or r should be a UnitNode - assignability?: { - l: BaseNode - r: BaseNode - } - union?: { - l: readonly BaseRoot[] - r: readonly BaseRoot[] - } - indiscriminableMorphs?: { - l: Node<"union"> - r: Node<"union"> - } - interesectedMorphs?: { - l: Node<"morph"> - r: Node<"morph"> - } -} - -export type DisjointKindEntries = entryOf[] - -export type SerializedPath = `[${string}]` - -export type DisjointsSources = { - [k in `${SerializedPath}`]: DisjointsAtPath +import { hasArkKind, pathToPropString } from "./utils.js" + +export interface DisjointEntry { + kind: kind + l: OperandsByDisjointKind[kind] + r: OperandsByDisjointKind[kind] + path: Key[] + optional: boolean } -export type DisjointsAtPath = { - [kind in DisjointKind]?: DisjointKinds[kind] +type OperandsByDisjointKind = { + domain: Node<"domain"> + unit: Node<"unit"> + proto: Node<"proto"> + presence: BaseRoot + range: Node + assignability: BaseNode + union: readonly BaseRoot[] } -export type DisjointSourceEntry = entryOf - -export type DisjointSource = Required[DisjointKind] - -export type FlatDisjointEntry = { - path: SerializedPath - kind: DisjointKind - disjoint: DisjointSource +export type DisjointEntryContext = { + path?: Key[] + optional?: true } -export type DisjointKind = keyof DisjointKinds - -export class Disjoint { - constructor(public sources: DisjointsSources) {} - - clone(): Disjoint { - return new Disjoint(this.sources) - } - - static from( +export class Disjoint extends Array { + static init( kind: kind, - l: Required[kind]["l"], - r: Required[kind]["r"] + l: OperandsByDisjointKind[kind], + r: OperandsByDisjointKind[kind], + ctx?: DisjointEntryContext ): Disjoint { return new Disjoint({ - "[]": { - [kind]: { - l, - r - } - } + kind, + l, + r, + path: ctx?.path ?? [], + optional: ctx?.optional ?? false }) } - static fromEntries(entries: DisjointKindEntries): Disjoint { - if (!entries.length) { - return throwInternalError( - "Unexpected attempt to create a disjoint from no entries" - ) - } - return new Disjoint({ "[]": fromEntries(entries) }) - } - - get flat(): FlatDisjointEntry[] { - return entriesOf(this.sources).flatMap(([path, disjointKinds]) => - entriesOf(disjointKinds).map(([kind, disjoint]) => ({ - path, - kind, - disjoint - })) - ) + add( + kind: kind, + l: OperandsByDisjointKind[kind], + r: OperandsByDisjointKind[kind], + ctx?: DisjointEntryContext + ): Disjoint { + this.push({ + kind, + l, + r, + path: ctx?.path ?? [], + optional: ctx?.optional ?? false + }) + return this } describeReasons(): string { - const reasons = this.flat - if (reasons.length === 1) { - const { path, disjoint } = reasons[0] - const pathString = JSON.parse(path).join(".") + if (this.length === 1) { + const { path, l, r } = this[0] + const pathString = pathToPropString(path) return `Intersection${ pathString && ` at ${pathString}` - } of ${describeReasons(disjoint)} results in an unsatisfiable type` + } of ${describeReasons(l, r)} results in an unsatisfiable type` } - return `The following intersections result in unsatisfiable types:\nā€¢ ${reasons - .map(({ path, disjoint }) => `${path}: ${describeReasons(disjoint)}`) - .join("\nā€¢ ")}` - } - - isEmpty(): boolean { - return this.flat.length === 0 + return `The following intersections result in unsatisfiable types:\nā€¢ ${this.map( + ({ path, l, r }) => `${path}: ${describeReasons(l, r)}` + ).join("\nā€¢ ")}` } throw(): never { @@ -143,44 +79,26 @@ export class Disjoint { } invert(): Disjoint { - const invertedEntries = entriesOf(this.sources).map( - ([path, disjoints]) => - [ - path, - flatMorph(disjoints, (kind, disjoint) => [ - kind, - { l: disjoint.r, r: disjoint.l } - ]) - ] as DisjointSourceEntry - ) - return new Disjoint(fromEntries(invertedEntries)) - } - - add(input: Disjoint): void { - entriesOf(input.sources).forEach(([path, disjoints]) => - Object.assign(this.sources[path] ?? {}, disjoints) - ) - } - - withPrefixKey(key: string | symbol): Disjoint { - const entriesWithPrefix = entriesOf(this.sources).map( - ([path, disjoints]): DisjointSourceEntry => { - const segments = JSON.parse(path) as string[] - segments.unshift(typeof key === "symbol" ? register(key) : key) - const pathWithPrefix = JSON.stringify(segments) as `[${string}]` - return [pathWithPrefix, disjoints] - } - ) - return new Disjoint(fromEntries(entriesWithPrefix)) + return this.map(entry => ({ + ...entry, + l: entry.r, + r: entry.l + })) as Disjoint } - toString(): string { - return printable(this.sources) + withPrefixKey(key: string | symbol, kind: PropKind): Disjoint { + return this.map(entry => ({ + ...entry, + path: [key, ...entry.path], + optional: entry.optional || kind === "optional" + })) as Disjoint } } -const describeReasons = (source: DisjointSource): string => - `${describeReason(source.l)} and ${describeReason(source.r)}` +export type DisjointKind = keyof OperandsByDisjointKind + +const describeReasons = (l: unknown, r: unknown): string => + `${describeReason(l)} and ${describeReason(r)}` const describeReason = (value: unknown): string => hasArkKind(value, "root") ? value.expression diff --git a/ark/schema/shared/intersections.ts b/ark/schema/shared/intersections.ts index 7caff275b3..31e2ce0152 100644 --- a/ark/schema/shared/intersections.ts +++ b/ark/schema/shared/intersections.ts @@ -88,13 +88,23 @@ export const intersectNodesRoot: InternalNodeIntersection = ( l, r, $ -) => intersectNodes(l, r, { $, invert: false, pipe: false }) +) => + intersectNodes(l, r, { + $, + invert: false, + pipe: false + }) export const pipeNodesRoot: InternalNodeIntersection = ( l, r, $ -) => intersectNodes(l, r, { $, invert: false, pipe: true }) +) => + intersectNodes(l, r, { + $, + invert: false, + pipe: true + }) export const intersectNodes: InternalNodeIntersection = ( l, diff --git a/ark/schema/structure/index.ts b/ark/schema/structure/indexed.ts similarity index 100% rename from ark/schema/structure/index.ts rename to ark/schema/structure/indexed.ts diff --git a/ark/schema/structure/prop.ts b/ark/schema/structure/prop.ts index 5e70f942c6..6055b4b372 100644 --- a/ark/schema/structure/prop.ts +++ b/ark/schema/structure/prop.ts @@ -55,7 +55,13 @@ export const intersectProps = ( const kind: PropKind = l.required || r.required ? "required" : "optional" if (value instanceof Disjoint) { if (kind === "optional") value = ctx.$.keywords.never.raw - else return value.withPrefixKey(l.compiledKey) + else { + // if either operand was optional, the Disjoint has to be treated as optional + return value.withPrefixKey( + l.key, + l.required && r.required ? "required" : "optional" + ) + } } if (kind === "required") { diff --git a/ark/schema/structure/sequence.ts b/ark/schema/structure/sequence.ts index 61422ec9fe..1f34a9c3e7 100644 --- a/ark/schema/structure/sequence.ts +++ b/ark/schema/structure/sequence.ts @@ -193,14 +193,14 @@ export const sequenceImplementation: nodeImplementationOf = const rootState = _intersectSequences({ l: l.tuple, r: r.tuple, - disjoint: new Disjoint({}), + disjoint: new Disjoint(), result: [], fixedVariants: [], ctx }) const viableBranches = - rootState.disjoint.isEmpty() ? + rootState.disjoint.length === 0 ? [rootState, ...rootState.fixedVariants] : rootState.fixedVariants @@ -405,7 +405,7 @@ const _intersectSequences = ( fixedVariants: [], r: rTail.map(element => ({ ...element, kind: "prefix" })) }) - if (postfixBranchResult.disjoint.isEmpty()) + if (postfixBranchResult.disjoint.length === 0) s.fixedVariants.push(postfixBranchResult) } else if ( rHead.kind === "prefix" && @@ -417,17 +417,18 @@ const _intersectSequences = ( fixedVariants: [], l: lTail.map(element => ({ ...element, kind: "prefix" })) }) - if (postfixBranchResult.disjoint.isEmpty()) + if (postfixBranchResult.disjoint.length === 0) s.fixedVariants.push(postfixBranchResult) } const result = intersectNodes(lHead.node, rHead.node, s.ctx) if (result instanceof Disjoint) { if (kind === "prefix" || kind === "postfix") { - s.disjoint.add( - result.withPrefixKey( + s.disjoint.push( + ...result.withPrefixKey( // TODO: more precise path handling for Disjoints - kind === "prefix" ? `${s.result.length}` : `-${lTail.length + 1}` + kind === "prefix" ? `${s.result.length}` : `-${lTail.length + 1}`, + "required" ) ) s.result = [...s.result, { kind, node: s.ctx.$.keywords.never.raw }] diff --git a/ark/schema/structure/structure.ts b/ark/schema/structure/structure.ts index 7aaeb70cbc..dc234d66d1 100644 --- a/ark/schema/structure/structure.ts +++ b/ark/schema/structure/structure.ts @@ -34,7 +34,7 @@ import type { TraverseApply } from "../shared/traversal.js" import { makeRootAndArrayPropertiesMutable } from "../shared/utils.js" -import type { IndexNode, IndexSchema } from "./index.js" +import type { IndexNode, IndexSchema } from "./indexed.js" import type { OptionalNode, OptionalSchema } from "./optional.js" import type { PropNode } from "./prop.js" import type { RequiredNode, RequiredSchema } from "./required.js" @@ -370,11 +370,15 @@ export const structureImplementation: nodeImplementationOf k => !lKey.allows(k) ) if (disjointRKeys.length) { - return Disjoint.from( - "presence", - ctx.$.keywords.never.raw, - r.propsByKey[disjointRKeys[0]]!.value - ).withPrefixKey(disjointRKeys[0]) + return new Disjoint( + ...disjointRKeys.map(k => ({ + kind: "presence" as const, + l: ctx.$.keywords.never.raw, + r: r.propsByKey[k]!.value, + path: [k], + optional: false + })) + ) } if (rInner.optional) @@ -401,11 +405,15 @@ export const structureImplementation: nodeImplementationOf k => !rKey.allows(k) ) if (disjointLKeys.length) { - return Disjoint.from( - "presence", - l.propsByKey[disjointLKeys[0]]!.value, - ctx.$.keywords.never.raw - ).withPrefixKey(disjointLKeys[0]) + return new Disjoint( + ...disjointLKeys.map(k => ({ + kind: "presence" as const, + l: l.propsByKey[k]!.value, + r: ctx.$.keywords.never.raw, + path: [k], + optional: false + })) + ) } if (lInner.optional) diff --git a/ark/type/CHANGELOG.md b/ark/type/CHANGELOG.md index afd3c9c6b8..e2565cbb49 100644 --- a/ark/type/CHANGELOG.md +++ b/ark/type/CHANGELOG.md @@ -1,5 +1,21 @@ # arktype +## 2.0.0-dev.24 + +### Fix constrained narrow/pipe tuple expression input inference + +Previously constraints were not stripped when inferring function inputs for tuple expressions like the following: + +```ts +// previously errored due to data being inferred as `number.moreThan<0>` +// now correctly inferred as number +const t = type(["number>0", "=>", data => data + 1]) +``` + +### Fix a bug where paths including optional keys could be included as candidates for discrimination (see https://github.com/arktypeio/arktype/issues/960) + +### Throw descriptive parse errors on unordered unions between indiscriminable morphs and other indeterminate type operations (see https://github.com/arktypeio/arktype/issues/967) + ## 2.0.0-dev.23 ### Add an `AnyType` type that allows a Type instance from any Scope diff --git a/ark/type/__tests__/completions.test.ts b/ark/type/__tests__/completions.test.ts index 71de9123d2..cdea79e852 100644 --- a/ark/type/__tests__/completions.test.ts +++ b/ark/type/__tests__/completions.test.ts @@ -4,15 +4,13 @@ import { scope, type } from "arktype" contextualize(() => { it("completes standalone keyword", () => { // @ts-expect-error - attest(() => type("s")).completions({ - s: ["string", "symbol", "semver"] - }) + attest(() => type("s")).completions({ s: ["semver", "string", "symbol"] }) }) it("completes within objects", () => { // @ts-expect-error attest(() => type({ a: "a", b: "b" })).completions({ - a: ["any", "alpha", "alphanumeric"], + a: ["alpha", "alphanumeric", "any"], b: ["bigint", "boolean"] }) }) @@ -20,7 +18,7 @@ contextualize(() => { it("completes within expressions", () => { // @ts-expect-error attest(() => type("string|n")).completions({ - "string|n": ["string|number", "string|null", "string|never"] + "string|n": ["string|never", "string|null", "string|number"] }) }) diff --git a/ark/type/__tests__/discrimination.test.ts b/ark/type/__tests__/discrimination.test.ts index 2ac60e80ec..0c03c96f97 100644 --- a/ark/type/__tests__/discrimination.test.ts +++ b/ark/type/__tests__/discrimination.test.ts @@ -1,9 +1,10 @@ import { attest, contextualize } from "@arktype/attest" +import { registeredReference } from "@arktype/util" import { scope, type } from "arktype" contextualize(() => { it("2 literal branches", () => { - // should not use a switch with <=2 branches to avoid visual clutter + // should not use a switch with <=2 branches to avoid needless convolution const t = type("'a'|'b'") attest(t.json).snap([{ unit: "a" }, { unit: "b" }]) attest(t.raw.hasKind("union") && t.raw.discriminantJson).snap({ @@ -87,18 +88,17 @@ contextualize(() => { attest(t.raw.hasKind("union") && t.raw.discriminantJson).equals(null) }) - // https://github.com/arktypeio/arktype/issues/960 - // it("discriminate optional key", () => { - // const t = type({ - // direction: "'forward' | 'backward'", - // "operator?": "'by'" - // }).or({ - // duration: "'s' | 'min' | 'h'", - // operator: "'to'" - // }) + it("discriminate optional key", () => { + const t = type({ + direction: "'forward' | 'backward'", + "operator?": "'by'" + }).or({ + duration: "'s' | 'min' | 'h'", + operator: "'to'" + }) - // attest(t.raw.hasKind("union") && t.raw.discriminantJson).equals(null) - // }) + attest(t.raw.hasKind("union") && t.raw.discriminantJson).equals(null) + }) it("default case", () => { const t = getPlaces().type([ @@ -169,4 +169,26 @@ contextualize(() => { const t = type("string[]|boolean[]") attest(t.raw.hasKind("union") && t.raw.discriminantJson).equals(null) }) + + it("discriminant path including symbol", () => { + const s = Symbol("lobmyS") + const sRef = registeredReference(s) + const t = type({ [s]: "0" }).or({ [s]: "1" }) + attest(t.raw.hasKind("union") && t.raw.discriminantJson).snap({ + kind: "unit", + path: [sRef], + cases: { + "0": true, + "1": true + } + }) + + attest(t.allows({ [s]: 0 })).equals(true) + attest(t.allows({ [s]: -1 })).equals(false) + + attest(t({ [s]: 1 })).equals({ [s]: 1 }) + attest(t({ [s]: 2 }).toString()).snap( + "value at [Symbol(lobmyS)] must be 0 or 1 (was 2)" + ) + }) }) diff --git a/ark/type/__tests__/expressions.test.ts b/ark/type/__tests__/expressions.test.ts index 524cd206cb..f20c95e63f 100644 --- a/ark/type/__tests__/expressions.test.ts +++ b/ark/type/__tests__/expressions.test.ts @@ -15,104 +15,104 @@ contextualize( // @ts-expect-error attest(() => type([""])).completions({ "": [ - "string", - "number", - "bigint", - "boolean", - "symbol", - "undefined", - "object", - "null", - "integer", + "...", + "===", "Array", "Date", "Error", "Function", "Map", + "Promise", + "Record", "RegExp", "Set", "WeakMap", "WeakSet", - "Promise", - "true", - "false", - "any", - "never", - "unknown", - "keyof", - "parse", - "void", - "format", - "url", "alpha", "alphanumeric", + "any", + "bigint", + "boolean", + "creditCard", "digits", + "email", + "false", + "format", + "instanceof", + "integer", + "ip", + "keyof", "lowercase", + "never", + "null", + "number", + "object", + "parse", + "semver", + "string", + "symbol", + "this", + "true", + "undefined", + "unknown", "uppercase", - "creditCard", - "email", + "url", "uuid", - "semver", - "ip", - "Record", - "instanceof", - "===", - "...", - "this" + "void" ] }) // @ts-expect-error attest(() => type(["string", ""])).completions({ "": [ - "string", - "number", - "bigint", - "boolean", - "symbol", - "undefined", - "object", - "null", - "integer", + "&", + "...", + ":", + "=>", + "?", + "@", "Array", "Date", "Error", "Function", "Map", + "Promise", + "Record", "RegExp", "Set", "WeakMap", "WeakSet", - "Promise", - "true", - "false", - "?", - "any", - "never", - "unknown", - "&", - "keyof", - "parse", - "void", - "format", - "url", + "[]", "alpha", "alphanumeric", + "any", + "bigint", + "boolean", + "creditCard", "digits", + "email", + "false", + "format", + "integer", + "ip", + "keyof", "lowercase", + "never", + "null", + "number", + "object", + "parse", + "semver", + "string", + "symbol", + "this", + "true", + "undefined", + "unknown", "uppercase", - "creditCard", - "email", + "url", "uuid", - "semver", - "ip", - "Record", - "[]", - "|", - ":", - "=>", - "@", - "...", - "this" + "void", + "|" ] }) }) diff --git a/ark/type/__tests__/pipe.test.ts b/ark/type/__tests__/pipe.test.ts index d60ddd07c2..cc187fca57 100644 --- a/ark/type/__tests__/pipe.test.ts +++ b/ark/type/__tests__/pipe.test.ts @@ -1,6 +1,14 @@ import { attest, contextualize } from "@arktype/attest" -import { assertNodeKind, type Out, type string } from "@arktype/schema" -import { scope, type Type, type } from "arktype" +import { + assertNodeKind, + writeIndiscriminableMorphMessage, + writeMorphIntersectionMessage, + type MoreThan, + type Out, + type of, + type string +} from "@arktype/schema" +import { scope, type, type Type } from "arktype" contextualize(() => { it("base", () => { @@ -397,11 +405,34 @@ contextualize(() => { it("discriminable tuple union", () => { const $ = scope({ a: () => $.type(["string"]).pipe(s => [...s, "!"]), - b: ["boolean"], + b: ["number"], c: () => $.type("a|b") }) const types = $.export() - attest<[boolean] | ((In: [string]) => Out)>(types.c.t) + + attest<[number] | ((In: [string]) => Out)>(types.c.t) + const expectedSerializedMorphs = + types.a.raw.assertHasKind("morph").serializedMorphs + + attest(types.c.raw.assertHasKind("union").discriminantJson).snap({ + kind: "domain", + path: ["0"], + cases: { + '"number"': { + sequence: { prefix: ["number"] }, + proto: "Array", + exactLength: 1 + }, + '"string"': { + in: { + sequence: { prefix: ["string"] }, + proto: "Array", + exactLength: 1 + }, + morphs: expectedSerializedMorphs + } + } + }) }) it("ArkTypeError not included in return", () => { @@ -448,120 +479,145 @@ contextualize(() => { attest Out>>(toMaybeNumber) }) - // TODO: reenable discrimination - // it("deep intersection", () => { - // const types = scope({ - // a: { a: ["number>0", "=>", (data) => data + 1] }, - // b: { a: "1" }, - // c: "a&b" - // }).export() - // attest Out }>>(types.c) - // attest(types.c.json).snap() - // }) - - // it("double intersection", () => { - // attest(() => - // scope({ - // a: ["boolean", "=>", (data) => `${data}`], - // b: ["boolean", "=>", (data) => `${data}!!!`], - // c: "a&b" - // }).export() - // ).throws.snap("ParseError: Invalid intersection of morphs") - // }) - - // it("undiscriminated union", () => { - // // TODO: fix - // // attest(() => { - // // scope({ - // // a: ["/.*/", "=>", (s) => s.trim()], - // // b: "string", - // // c: "a|b" - // // }).export() - // // }).throws(writeUndiscriminableMorphUnionMessage("/")) - // }) - - // it("deep double intersection", () => { - // attest(() => { - // scope({ - // a: { a: ["boolean", "=>", (data) => `${data}`] }, - // b: { a: ["boolean", "=>", (data) => `${data}!!!`] }, - // c: "a&b" - // }).export() - // }).throws.snap("ParseError: Invalid intersection of morphs") - // }) - - // it("deep undiscriminated union", () => { - // attest(() => { - // scope({ - // a: { a: ["string", "=>", (s) => s.trim()] }, - // b: { a: "'foo'" }, - // c: "a|b" - // }).export() - // }).throws(writeUndiscriminableMorphUnionMessage("/")) - // }) - - // it("deep undiscriminated reference", () => { - // const $ = scope({ - // a: { a: ["string", "=>", (s) => s.trim()] }, - // b: { a: "boolean" }, - // c: { b: "boolean" } - // }) - // const t = $.type("a|b") - // attest< - // Type< - // | { - // a: (In: string) => Out - // } - // | { - // a: boolean - // } - // > - // >(t) - - // attest(() => { - // scope({ - // a: { a: ["string", "=>", (s) => s.trim()] }, - // b: { b: "boolean" }, - // c: "a|b" - // }).export() - // }).throws(writeUndiscriminableMorphUnionMessage("/")) - // }) - - // it("array double intersection", () => { - // // attest(() => { - // // scope({ - // // a: { a: ["number>0", "=>", (data) => data + 1] }, - // // b: { a: ["number>0", "=>", (data) => data + 2] }, - // // c: "a[]&b[]" - // // }).export() - // // }).throws( - // // "At [index]/a: Intersection of morphs results in an unsatisfiable type" - // // ) - // }) - - // it("undiscriminated morph at path", () => { - // attest(() => { - // scope({ - // a: { a: ["string", "=>", (s) => s.trim()] }, - // b: { b: "boolean" }, - // c: { key: "a|b" } - // }).export() - // }).throws(writeUndiscriminableMorphUnionMessage("key")) - // }) - - // it("helper morph intersection", () => { - // attest(() => - // type("string") - // .morph((s) => s.length) - // .and(type("string").morph((s) => s.length)) - // ).throws("Intersection of morphs results in an unsatisfiable type") - // }) - - // it("union helper undiscriminated", () => { - // attest(() => - // type("string") - // .morph((s) => s.length) - // .or("'foo'") - // ).throws(writeUndiscriminableMorphUnionMessage("/")) - // }) + it("deep intersection", () => { + const types = scope({ + a: { a: ["number>0", "=>", data => data + 1] }, + b: { a: "1" }, + c: "a&b" + }).export() + attest<{ a: (In: of<1, MoreThan<0>>) => Out }>(types.c.t) + const { serializedMorphs } = + types.a.raw.firstReferenceOfKindOrThrow("morph") + + attest(types.c.json).snap({ + required: [ + { key: "a", value: { in: { unit: 1 }, morphs: serializedMorphs } } + ], + domain: "object" + }) + }) + + it("morph intersection", () => { + attest(() => + scope({ + a: ["string", "=>", data => `${data}`], + b: ["string", "=>", data => `${data}!!!`], + c: "a&b" + }).export() + ).throws( + writeMorphIntersectionMessage( + "(In: string) => Out", + "(In: string) => Out" + ) + ) + }) + + it("indiscriminable union", () => { + attest(() => { + scope({ + a: ["/.*/", "=>", s => s.trim()], + b: "string", + c: "a|b" + }).export() + }).throws( + writeIndiscriminableMorphMessage( + "(In: string /.*/) => Out", + "string" + ) + ) + }) + + it("deep morph intersection", () => { + attest(() => { + scope({ + a: { a: ["number", "=>", data => `${data}`] }, + b: { a: ["number", "=>", data => `${data}!!!`] }, + c: "a&b" + }).export() + }).throws( + writeMorphIntersectionMessage( + "(In: number) => Out", + "(In: number) => Out" + ) + ) + }) + + it("deep indiscriminable", () => { + const $ = scope({ + a: { foo: ["string", "=>", s => s.trim()] }, + b: { foo: "symbol" }, + c: { bar: "symbol" } + }) + + // this is fine as a | b can be discriminated via foo + const t = $.type("a|b") + attest< + | { + foo: (In: string) => Out + } + | { + foo: symbol + } + >(t.t) + + attest(() => $.type("a|c")).throws( + writeIndiscriminableMorphMessage( + "{ foo: (In: string) => Out }", + "{ bar: symbol }" + ) + ) + }) + + it("array double intersection", () => { + attest(() => { + scope({ + a: { a: ["number>0", "=>", data => data + 1] }, + b: { a: ["number>0", "=>", data => data + 2] }, + c: "a[]&b[]" + }).export() + }).throws( + writeMorphIntersectionMessage( + "(In: number >0) => Out", + "(In: number >0) => Out" + ) + ) + }) + + it("undiscriminated morph at path", () => { + attest(() => { + scope({ + a: { a: ["string", "=>", s => s.trim()] }, + b: { b: "bigint" }, + c: { key: "a|b" } + }).export() + }).throws( + writeIndiscriminableMorphMessage( + "{ a: (In: string) => Out }", + "{ b: bigint }" + ) + ) + }) + + it("helper morph intersection", () => { + attest(() => + type("string") + .pipe(s => s.length) + .and(type("string").pipe(s => s.length)) + ).throws( + writeMorphIntersectionMessage( + "(In: string) => Out", + "(In: string) => Out" + ) + ) + }) + + it("union helper undiscriminated", () => { + attest(() => + type("string") + .pipe(s => s.length) + .or("'foo'") + ).throws( + writeIndiscriminableMorphMessage("(In: string) => Out", '"foo"') + ) + }) }) diff --git a/ark/type/__tests__/string.test.ts b/ark/type/__tests__/string.test.ts index d8ffc8e031..8de47ddcb1 100644 --- a/ark/type/__tests__/string.test.ts +++ b/ark/type/__tests__/string.test.ts @@ -40,7 +40,7 @@ contextualize(() => { it("shallow multi autocomplete", () => { // @ts-expect-error - attest(() => type("s")).completions({ s: ["string", "symbol", "semver"] }) + attest(() => type("s")).completions({ s: ["semver", "string", "symbol"] }) }) it("post-operator autocomplete", () => { diff --git a/ark/type/__tests__/union.test.ts b/ark/type/__tests__/union.test.ts index 85c18bcdaa..ed7989e000 100644 --- a/ark/type/__tests__/union.test.ts +++ b/ark/type/__tests__/union.test.ts @@ -203,7 +203,7 @@ contextualize(() => { it("root autocompletions", () => { // @ts-expect-error attest(() => type({ a: "s" }, "|", { b: "boolean" })).completions({ - s: ["string", "symbol", "semver"] + s: ["semver", "string", "symbol"] }) // @ts-expect-error attest(() => type({ a: "string" }, "|", { b: "b" })).completions({ diff --git a/ark/type/package.json b/ark/type/package.json index a064ddbdd5..cb42f5fdf2 100644 --- a/ark/type/package.json +++ b/ark/type/package.json @@ -1,7 +1,7 @@ { "name": "arktype", "description": "TypeScript's 1:1 validator, optimized from editor to runtime", - "version": "2.0.0-dev.23", + "version": "2.0.0-dev.24", "license": "MIT", "author": { "name": "David Blass", diff --git a/ark/type/parser/tuple.ts b/ark/type/parser/tuple.ts index 1106333288..b9ad57a4a5 100644 --- a/ark/type/parser/tuple.ts +++ b/ark/type/parser/tuple.ts @@ -12,7 +12,7 @@ import { type UnionChildKind, type UnknownRoot, type distillConstrainableIn, - type distillConstrainableOut, + type distillOut, type inferIntersection, type inferMorphOut, type inferPredicate @@ -376,12 +376,9 @@ export type validateInfixExpression = def[1] extends "|" ? validateDefinition : def[1] extends "&" ? validateDefinition : def[1] extends ":" ? - Predicate>> + Predicate>> : def[1] extends "=>" ? - Morph< - distillConstrainableOut>, - unknown - > + Morph>, unknown> : def[1] extends "@" ? BaseMeta | string : validateDefinition ] diff --git a/ark/util/arrays.ts b/ark/util/arrays.ts index 7006eda2bc..c57dd2c9dc 100644 --- a/ark/util/arrays.ts +++ b/ark/util/arrays.ts @@ -247,3 +247,6 @@ export const groupBy = >( result[key].push(item) return result }, {}) + +export const arrayEquals = (l: array, r: array) => + l.length === r.length && l.every((lItem, i) => lItem === r[i])