Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: intersection inference across scopes #848

Merged
merged 2 commits into from
Sep 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions dev/configs/.changeset/beige-snails-wash.md
Original file line number Diff line number Diff line change
@@ -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" }])
```
3 changes: 1 addition & 2 deletions dev/test/intersection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
})
Expand Down
11 changes: 3 additions & 8 deletions dev/test/morph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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"
)
})
Expand Down Expand Up @@ -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"
)
})
Expand Down
35 changes: 35 additions & 0 deletions dev/test/scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
121 changes: 48 additions & 73 deletions src/parse/ast/intersection.ts
Original file line number Diff line number Diff line change
@@ -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<l, r> = inferIntersectionRecurse<l, r, []>

type inferIntersectionRecurse<
l,
r,
path extends string[]
> = path["length"] extends 10
? l & r
: l extends never
export type inferIntersection<l, r> = [l] extends [never]
? never
: [r] extends [never]
? never
: r extends never
: [l & r] extends [never]
? never
: l & r extends never
? error<writeImplicitNeverMessage<path, "Intersection">>
: isAny<l | r> extends true
? any
: l extends ParsedMorph<infer lIn, infer lOut>
? r extends ParsedMorph
? error<writeImplicitNeverMessage<path, "Intersection", "of morphs">>
? never
: (In: evaluate<lIn & r>) => lOut
: r extends ParsedMorph<infer rIn, infer rOut>
? (In: evaluate<rIn & l>) => rOut
: [l, r] extends [Dict, Dict]
? bubblePropErrors<
evaluate<
: intersectObjects<l, r> extends infer result
? result
: never

type intersectObjects<l, r> = [l, r] extends [object, object]
? [l, r] extends [infer lList extends List, infer rList extends List]
? inferArrayIntersection<lList, rList>
: evaluate<
{
[k in keyof l as k extends string
? k
: never]: k extends string
? k extends keyof r
? inferIntersectionRecurse<l[k], r[k], [...path, k]>
: l[k]
: never
[k in keyof l]: k extends keyof r
? inferIntersection<l[k], r[k]>
: l[k]
} & Omit<r, keyof l>
>
>
: l extends List
? r extends List
? inferArrayIntersection<l, r, path>
: l & r
: l & r

type inferArrayIntersection<
l extends List,
r extends List,
path extends string[]
> = isTuple<l> extends true
? {
[i in keyof l]: inferIntersectionRecurse<
l[i],
r[i & keyof r],
[...path, `${i}`]
> extends infer result
? tryCatch<result, result>
: never
}
: isTuple<r> extends true
? {
[i in keyof r]: inferIntersectionRecurse<
l[i & keyof l],
r[i],
[...path, `${i}`]
> extends infer result
? tryCatch<result, result>
: never
}
: inferIntersectionRecurse<
l[number],
r[number],
[...path, MappedKeys["index"]]
> extends infer result
? tryCatch<result, result[]>
: never

type isTuple<list extends List> = number extends list["length"] ? false : true

type bubblePropErrors<o> = extractValues<o, error> extends never
? o
: extractValues<o, error>
result extends List = []
> = [l, r] extends [
[infer lHead, ...infer lTail],
[infer rHead, ...infer rTail]
]
? inferArrayIntersection<
lTail,
rTail,
[...result, inferIntersection<lHead, rHead>]
>
: l extends [infer lHead, ...infer lTail]
? r extends []
? // l is longer tuple than r, unsatisfiable
never
: inferArrayIntersection<
lTail,
r,
[...result, inferIntersection<lHead, r[number]>]
>
: r extends [infer rHead, ...infer rTail]
? l extends []
? // r is longer tuple than l, unsatisfiable
never
: inferArrayIntersection<
l,
rTail,
[...result, inferIntersection<l[number], rHead>]
>
: [number, number] extends [l["length"], r["length"]]
? [...result, ...inferIntersection<l[number], r[number]>[]]
: result

export const compileDisjointReasonsMessage = (disjoints: DisjointsByPath) => {
const paths = objectKeysOf(disjoints)
Expand Down