diff --git a/dev/configs/.changeset/beige-snails-wash.md b/dev/configs/.changeset/beige-snails-wash.md new file mode 100644 index 0000000000..723733d7cc --- /dev/null +++ b/dev/configs/.changeset/beige-snails-wash.md @@ -0,0 +1,31 @@ +--- +"arktype": patch +--- + +# Fixes a bug causing intersections including cross scope references to be inferred as `unknown` + +Unfortunately, some cross-scope operations will still result in an error at runtime. You will know at build time if this occurs by a message in an intersection like "Unable to resolve alias 'myExternalAlias'". The workaround is to use the in-scope type parser as follows until next release for these scenarios: + +Unions: + +```ts +const $ = scope({ + a: "'abc'", + b: { "c?": "a" } +}) +const types = $.compile() +// This fails if you don't use scoped type for now, fixing in next release +const t = $.type([types.b, "|", { extraProp: "string" }]) +``` + +Intersections: + +```ts +const $ = scope({ + a: "'abc'", + b: { "c?": "a" } +}) +const types = $.compile() +// This fails if you don't use scoped type for now, fixing in next release +const t = $.type([types.b, "&", { extraProp: "string" }]) +``` diff --git a/dev/test/intersection.test.ts b/dev/test/intersection.test.ts index 0bbabe9d76..d4041ba791 100644 --- a/dev/test/intersection.test.ts +++ b/dev/test/intersection.test.ts @@ -184,8 +184,7 @@ describe("intersection", () => { ) }) it("implicit never", () => { - // @ts-expect-error - attest(() => type("string&number")).throwsAndHasTypeError( + attest(() => type("string&number")).throws( "results in an unsatisfiable type" ) }) diff --git a/dev/test/morph.test.ts b/dev/test/morph.test.ts index d60b92c6d1..81db5d76d7 100644 --- a/dev/test/morph.test.ts +++ b/dev/test/morph.test.ts @@ -290,12 +290,9 @@ describe("morph", () => { scope({ a: ["boolean", "|>", (data) => `${data}`], b: ["boolean", "|>", (data) => `${data}!!!`], - // @ts-expect-error c: "a&b" }).compile() - }).throwsAndHasTypeError( - "Intersection of morphs results in an unsatisfiable type" - ) + }).throws("Intersection of morphs results in an unsatisfiable type") }) it("undiscriminated union", () => { attest(() => { @@ -312,10 +309,9 @@ describe("morph", () => { scope({ a: { a: ["boolean", "|>", (data) => `${data}`] }, b: { a: ["boolean", "|>", (data) => `${data}!!!`] }, - // @ts-expect-error c: "a&b" }).compile() - }).throwsAndHasTypeError( + }).throws( "At a: Intersection of morphs results in an unsatisfiable type" ) }) @@ -354,10 +350,9 @@ describe("morph", () => { scope({ a: { a: ["number>0", "|>", (data) => data + 1] }, b: { a: ["number>0", "|>", (data) => data + 2] }, - // @ts-expect-error c: "a[]&b[]" }).compile() - }).throwsAndHasTypeError( + }).throws( "At [index]/a: Intersection of morphs results in an unsatisfiable type" ) }) diff --git a/dev/test/scope.test.ts b/dev/test/scope.test.ts index f9f70a79e1..ab3107d6fa 100644 --- a/dev/test/scope.test.ts +++ b/dev/test/scope.test.ts @@ -21,6 +21,41 @@ describe("scope", () => { scope({ a: type("strong") }) ).throwsAndHasTypeError(writeUnresolvableMessage("strong")) }) + it("resolves intersections across scopes", () => { + const $ = scope({ + a: "'abc'", + b: { "c?": "a" } + }) + const types = $.compile() + // This fails if you don't use scoped type for now, fixing in next release + const t = $.type([types.b, "&", { extraProp: "string" }]) + attest(t.infer).typed as { c?: "abc"; extraProp: string } + attest(t.node).snap({ + object: { props: { c: ["?", "a"], extraProp: "string" } } + }) + }) + it("resolves unions across scopes", () => { + const $ = scope({ + a: "'abc'", + b: { "c?": "a" } + }) + const types = $.compile() + // This fails if you don't use scoped type for now, fixing in next release + const t = $.type([types.b, "|", { extraProp: "string" }]) + attest(t.infer).typed as + | { + c?: "abc" + } + | { + extraProp: string + } + attest(t.node).snap({ + object: [ + { props: { c: ["?", "a"] } }, + { props: { extraProp: "string" } } + ] + }) + }) it("interdependent", () => { const types = scope({ a: "string>5", diff --git a/src/parse/ast/intersection.ts b/src/parse/ast/intersection.ts index d238dc4ea2..9b9650d74a 100644 --- a/src/parse/ast/intersection.ts +++ b/src/parse/ast/intersection.ts @@ -1,99 +1,74 @@ import type { DisjointsByPath } from "../../nodes/compose.js" import { disjointDescriptionWriters } from "../../nodes/compose.js" -import type { MappedKeys } from "../../nodes/rules/props.js" -import type { - asConst, - Dict, - error, - evaluate, - extractValues, - isAny, - List, - tryCatch -} from "../../utils/generics.js" +import type { asConst, evaluate, isAny, List } from "../../utils/generics.js" import { objectKeysOf } from "../../utils/generics.js" import type { Path, pathToString } from "../../utils/paths.js" import type { ParsedMorph } from "./morph.js" -export type inferIntersection = inferIntersectionRecurse - -type inferIntersectionRecurse< - l, - r, - path extends string[] -> = path["length"] extends 10 - ? l & r - : l extends never +export type inferIntersection = [l] extends [never] + ? never + : [r] extends [never] ? never - : r extends never + : [l & r] extends [never] ? never - : l & r extends never - ? error> : isAny extends true ? any : l extends ParsedMorph ? r extends ParsedMorph - ? error> + ? never : (In: evaluate) => lOut : r extends ParsedMorph ? (In: evaluate) => rOut - : [l, r] extends [Dict, Dict] - ? bubblePropErrors< - evaluate< + : intersectObjects extends infer result + ? result + : never + +type intersectObjects = [l, r] extends [object, object] + ? [l, r] extends [infer lList extends List, infer rList extends List] + ? inferArrayIntersection + : evaluate< { - [k in keyof l as k extends string - ? k - : never]: k extends string - ? k extends keyof r - ? inferIntersectionRecurse - : l[k] - : never + [k in keyof l]: k extends keyof r + ? inferIntersection + : l[k] } & Omit > - > - : l extends List - ? r extends List - ? inferArrayIntersection - : l & r : l & r type inferArrayIntersection< l extends List, r extends List, - path extends string[] -> = isTuple extends true - ? { - [i in keyof l]: inferIntersectionRecurse< - l[i], - r[i & keyof r], - [...path, `${i}`] - > extends infer result - ? tryCatch - : never - } - : isTuple extends true - ? { - [i in keyof r]: inferIntersectionRecurse< - l[i & keyof l], - r[i], - [...path, `${i}`] - > extends infer result - ? tryCatch - : never - } - : inferIntersectionRecurse< - l[number], - r[number], - [...path, MappedKeys["index"]] - > extends infer result - ? tryCatch - : never - -type isTuple = number extends list["length"] ? false : true - -type bubblePropErrors = extractValues extends never - ? o - : extractValues + result extends List = [] +> = [l, r] extends [ + [infer lHead, ...infer lTail], + [infer rHead, ...infer rTail] +] + ? inferArrayIntersection< + lTail, + rTail, + [...result, inferIntersection] + > + : l extends [infer lHead, ...infer lTail] + ? r extends [] + ? // l is longer tuple than r, unsatisfiable + never + : inferArrayIntersection< + lTail, + r, + [...result, inferIntersection] + > + : r extends [infer rHead, ...infer rTail] + ? l extends [] + ? // r is longer tuple than l, unsatisfiable + never + : inferArrayIntersection< + l, + rTail, + [...result, inferIntersection] + > + : [number, number] extends [l["length"], r["length"]] + ? [...result, ...inferIntersection[]] + : result export const compileDisjointReasonsMessage = (disjoints: DisjointsByPath) => { const paths = objectKeysOf(disjoints)