diff --git a/ark/attest/__tests__/assertions.test.ts b/ark/attest/__tests__/assertions.test.ts index 6d134f0883..8cf902961f 100644 --- a/ark/attest/__tests__/assertions.test.ts +++ b/ark/attest/__tests__/assertions.test.ts @@ -1,6 +1,8 @@ import { attest } from "@ark/attest" import { MissingSnapshotError } from "@ark/attest/internal/assert/assertions.ts" import { attestInternal } from "@ark/attest/internal/assert/attest.ts" +import { registeredReference } from "@ark/schema" +import { register } from "@ark/util" import { type } from "arktype" import * as assert from "node:assert/strict" @@ -168,11 +170,14 @@ describe("type assertions", () => { }) it("does not boom on Type comparison", () => { + const expectedRef = register(type.number) + const actualRef = register(type.string) + // @ts-expect-error attest(() => attest(type.string).equals(type.number)).throws - .snap(`AssertionError [ERR_ASSERTION]: Assertion including at least one function or object was not between reference equal items -Expected: Function(fn10) -Actual: Function(fn11)`) + .equals(`AssertionError [ERR_ASSERTION]: Assertion including at least one function or object was not between reference equal items +Expected: Function(${expectedRef}) +Actual: Function(${actualRef})`) }) it("doesn't boom on ArkErrors vs plain object", () => { diff --git a/ark/attest/package.json b/ark/attest/package.json index 6161e828ee..0a4b0a529a 100644 --- a/ark/attest/package.json +++ b/ark/attest/package.json @@ -46,6 +46,7 @@ "@typescript/analyze-trace": "0.10.1", "@typescript/vfs": "1.6.1", "arktype": "workspace:*", + "@ark/schema": "workspace:*", "prettier": "3.6.2" }, "devDependencies": { diff --git a/ark/json-schema/__tests__/array.test.ts b/ark/json-schema/__tests__/array.test.ts index 0b5f024006..8d4e7844c9 100644 --- a/ark/json-schema/__tests__/array.test.ts +++ b/ark/json-schema/__tests__/array.test.ts @@ -140,6 +140,16 @@ contextualize(() => { ) }) + it("minItems (0)", () => { + const tMinItems = jsonSchemaToType({ + type: "array", + minItems: 0, + items: { type: "string" } + }) + + attest(tMinItems.expression).snap("string[]") + }) + it("uniqueItems", () => { const tUniqueItems = jsonSchemaToType({ type: "array", diff --git a/ark/json-schema/__tests__/string.test.ts b/ark/json-schema/__tests__/string.test.ts index 1ff8258b11..a024564936 100644 --- a/ark/json-schema/__tests__/string.test.ts +++ b/ark/json-schema/__tests__/string.test.ts @@ -1,5 +1,6 @@ import { attest, contextualize } from "@ark/attest" import { jsonSchemaToType } from "@ark/json-schema" +import type { JsonSchemaOrBoolean } from "@ark/schema" contextualize(() => { it("type string", () => { @@ -57,4 +58,26 @@ contextualize(() => { // https://json-schema.org/draft-07/draft-handrews-json-schema-validation-01#rfc.section.4.3 attest(tPatternString.allows("expression")).equals(true) }) + + it("string enums", () => { + const enumKeys = ["keyOne", "keyTwo"] + + const stringEnums = jsonSchemaToType({ + type: "string", + enum: enumKeys + }) + + attest(stringEnums.expression).snap('"keyOne" | "keyTwo"') + }) + + it("minLength (0)", () => { + const schema = { + type: "string", + minLength: 0 + } satisfies JsonSchemaOrBoolean + const pattern = jsonSchemaToType(schema) + + attest(() => jsonSchemaToType(schema)) + attest(pattern.expression).snap("string") + }) }) diff --git a/ark/json-schema/common.ts b/ark/json-schema/common.ts index 9f7915507e..51324bdf12 100644 --- a/ark/json-schema/common.ts +++ b/ark/json-schema/common.ts @@ -12,5 +12,5 @@ export const parseCommonJsonSchema = ( return type.unit(jsonSchema.const) } - if ("enum" in jsonSchema) return type.enumerated(jsonSchema.enum) + if ("enum" in jsonSchema) return type.enumerated(...jsonSchema.enum) } diff --git a/ark/repo/scratch.ts b/ark/repo/scratch.ts index a8ae70c751..bfc484c1d8 100644 --- a/ark/repo/scratch.ts +++ b/ark/repo/scratch.ts @@ -1,5 +1,16 @@ -import { regex } from "arkregex" +import { scope, type } from "arktype" -const S = regex("^a(?b(c)d)?e\\1\\2?$") +const $ = scope({ + Foo: { + "oneOf?": "Bar[]" // NB: don't get the error if this is not an array + }, + Bar: "Foo" +}).export() -S +const baz = $.Bar.pipe((_: object): type.Any | undefined => { + console.log("this never gets logged since pipe isn't entered") + return type("string") +}) + +const r = baz({ oneOf: [{}] }) // throws "TypeError: this.Foo1Apply is not a function" +console.log(r?.toString()) diff --git a/ark/schema/__tests__/bounds.test.ts b/ark/schema/__tests__/bounds.test.ts index a6efffaae8..485aea89da 100644 --- a/ark/schema/__tests__/bounds.test.ts +++ b/ark/schema/__tests__/bounds.test.ts @@ -98,6 +98,11 @@ contextualize(() => { ) }) + it("minLength 0 reduces to unconstrained", () => { + const T = rootSchema({ domain: "string", minLength: 0 }) + attest(T.expression).snap("string") + }) + for (const [min, max] of entriesOf(boundKindPairsByLower)) { describe(`${min}/${max}`, () => { const basis = diff --git a/ark/schema/__tests__/jsonSchema.test.ts b/ark/schema/__tests__/jsonSchema.test.ts index 40443dc0fe..a036870bc2 100644 --- a/ark/schema/__tests__/jsonSchema.test.ts +++ b/ark/schema/__tests__/jsonSchema.test.ts @@ -250,6 +250,9 @@ contextualize(() => { { type: "boolean" }, { type: "null" } ] + }, + union7: { + anyOf: [{ $ref: "#/$defs/intersection11" }, { type: "boolean" }] } } }) diff --git a/ark/schema/constraint.ts b/ark/schema/constraint.ts index 4f23842b97..a9640277f4 100644 --- a/ark/schema/constraint.ts +++ b/ark/schema/constraint.ts @@ -145,6 +145,9 @@ export const constraintKeyParser = return nodes.sort((l, r) => (l.hash < r.hash ? -1 : 1)) as never } const child = ctx.$.node(kind, schema) + // If the constraint was reduced to a root node (like unknown for minLength: 0), + // omit it from the schema since it's trivially satisfied + if (child.isRoot()) return return (child.hasOpenIntersection() ? [child] : child) as never } diff --git a/ark/schema/roots/intersection.ts b/ark/schema/roots/intersection.ts index d2f8060fbc..2ba2b74ccc 100644 --- a/ark/schema/roots/intersection.ts +++ b/ark/schema/roots/intersection.ts @@ -426,7 +426,7 @@ const writeIntersectionExpression = (node: Intersection.Node) => { .map(n => n.expression) .join(" & ") - const fullExpression = `${basisExpression}${basisExpression ? " " : ""}${refinementsExpression}` + const fullExpression = `${basisExpression}${basisExpression && refinementsExpression ? " " : ""}${refinementsExpression}` if (fullExpression === "Array == 0") return "[]" diff --git a/ark/schema/roots/union.ts b/ark/schema/roots/union.ts index 4d1735a0d1..535a1449e5 100644 --- a/ark/schema/roots/union.ts +++ b/ark/schema/roots/union.ts @@ -486,7 +486,7 @@ export class UnionNode extends BaseRoot { } discriminate(): Discriminant | null { - if (this.branches.length < 2 || this.isCyclic) return null + if (this.branches.length < 2) return null if (this.unitBranches.length === this.branches.length) { const cases = flatMorph(this.unitBranches, (i, n) => [ `${(n.rawIn as Unit.Node).serializedValue}`, diff --git a/ark/type/__tests__/discrimination.test.ts b/ark/type/__tests__/discrimination.test.ts index e4607f966b..dce3b4394f 100644 --- a/ark/type/__tests__/discrimination.test.ts +++ b/ark/type/__tests__/discrimination.test.ts @@ -490,4 +490,79 @@ contextualize(() => { attest(T.assert([])).equals([]) }) + + // https://github.com/arktypeio/arktype/issues/1547 + it("discriminates cyclic union on nested path", () => { + const s = scope({ + AChild: { type: "'AChild'", children: "(AParent)[] > 0" }, + AParent: { type: "'AParent'", children: "(AChild)[] > 0" }, + BChild: { type: "'BChild'", children: "unknown[]" }, + BParent: { + type: "'BParent'", + layout: "number[]", + children: "(BChild)[] > 0" + } + }) + + const Thing = s.type("AParent | BParent") + + attest(Thing.internal.assertHasKind("union").discriminantJson).snap({ + kind: "unit", + path: ["type"], + cases: { + '"BParent"': { + required: [ + { + key: "children", + value: { + sequence: { + required: [ + { key: "children", value: "Array" }, + { key: "type", value: { unit: "BChild" } } + ], + domain: "object" + }, + proto: "Array", + minLength: 1 + } + }, + { key: "layout", value: { sequence: "number", proto: "Array" } } + ] + }, + '"AParent"': { + required: [ + { + key: "children", + value: { + sequence: { + required: [ + { + key: "children", + value: { + sequence: "$AParent", + proto: "Array", + minLength: 1 + } + }, + { key: "type", value: { unit: "AChild" } } + ], + domain: "object" + }, + proto: "Array", + minLength: 1 + } + } + ] + } + } + }) + + attest( + Thing({ + type: "BParent", + layout: "", + children: [{ type: "BChild", children: [] }] + }).toString() + ).snap("layout must be an array (was string)") + }) }) diff --git a/ark/type/__tests__/keywords/object.test.ts b/ark/type/__tests__/keywords/object.test.ts index d8f9960643..33149104b9 100644 --- a/ark/type/__tests__/keywords/object.test.ts +++ b/ark/type/__tests__/keywords/object.test.ts @@ -26,7 +26,7 @@ contextualize(() => { attest(Json([])).equals([]) attest(Json(5)?.toString()).snap("must be an object (was a number)") attest(Json({ foo: [5n] })?.toString()).snap( - 'foo["0"] must be an object, a number, a string, false, null or true (was 5n) or foo must be a number, a string, false, null or true (was ["5n"])' + 'foo["0"] must be an object (was a bigint)' ) }) @@ -37,9 +37,9 @@ contextualize(() => { attest(out).snap('{"foo":"bar"}') - // this error kind of sucks, would not be sad if it was discriminated and changed + // this error kind of sucks, should have more discriminant context attest(stringify({ foo: undefined }).toString()).snap( - "foo must be an object, a number, a string, false, null or true (was undefined)" + "foo must be an object (was undefined)" ) // has declared out diff --git a/ark/type/__tests__/pipe.test.ts b/ark/type/__tests__/pipe.test.ts index 4b536d6128..721168c467 100644 --- a/ark/type/__tests__/pipe.test.ts +++ b/ark/type/__tests__/pipe.test.ts @@ -895,7 +895,7 @@ contextualize(() => { attest(indiscriminable).throws .snap(`ParseError: An unordered union of a type including a morph and a type with overlapping input is indeterminate: -Left: { foo: (In: string ) => Out | false | true } +Left: { foo: (In: string) => Out | false | true } Right: { foo: (In: string) => Out<{ [string]: $jsonObject | number | string | false | null | true }> | false | true }`) }) diff --git a/ark/type/__tests__/realWorld.test.ts b/ark/type/__tests__/realWorld.test.ts index 2000ee3335..33539ddc9c 100644 --- a/ark/type/__tests__/realWorld.test.ts +++ b/ark/type/__tests__/realWorld.test.ts @@ -525,7 +525,7 @@ nospace must be matched by ^\\S*$ (was "One space")`) box: { box: { box: {} } } }) attest(box({ box: { box: { box: "whoops" } } })?.toString()).snap( - 'box.box.box must be an object (was a string) or must be null (was {"box":{"box":{"box":"whoops"}}})' + "box.box.box must be an object (was a string)" ) }) @@ -1420,4 +1420,130 @@ Right: { x: number, y: number, + (undeclared): delete }`) date2 must be a parsable date (was "") date3 must be a parsable date (was "")`) }) + + // https://github.com/arktypeio/arktype/issues/1188 + it("cyclic discriminated union issue 1", () => { + let wasPiped = false + + const $ = scope({ + Foo: { + "oneOf?": "Bar[]" // NB: don't get the error if this is not an array + }, + Bar: "Foo" + }).export() + + const baz = $.Bar.pipe((_: object): type.Any | undefined => { + wasPiped = true + return type("string") + }) + + // previously threw "TypeError: this.Foo1Apply is not a function" + const r = baz({ oneOf: [{}] }) + + attest(wasPiped).equals(true) + attest(r?.toString()).snap("Type") + }) + + // https://github.com/arktypeio/arktype/issues/1367 + it("cyclic discriminated union issue 2", () => { + const componentModule = type.module({ + container: { + type: "'container'", + content: "component" + }, + + flexbox: { + type: "'flexbox'", + items: "component" + }, + + tabsItem: { + id: "string", + title: "component", + content: "component" + }, + tabs: { + type: "'tabs'", + items: "tabsItem[]" + }, + + singleComponent: "string | flexbox | tabs", + component: "singleComponent | singleComponent[]" + }) + const componentSchema = componentModule.component + + const component: typeof componentSchema.infer = { + type: "tabs", + items: [ + { + id: "tab-id", + title: "tab-title", + content: [] + } + ] + } + + const result = componentSchema(component) + + attest(result).equals(component) + }) + + // https://github.com/arktypeio/arktype/issues/1209 + it("cyclic discriminated union issue 3", () => { + const $ = scope({ + literal: '"foo"', + record: { + "[string]": "value" + }, + value: "literal|literal[]|record" + }).export() + + const result = $.value({}) + + attest(result).equals({}) + }) + + // https://github.com/arktypeio/arktype/issues/1284 + it("cyclic discriminated union issue 4", () => { + const ruleset = scope({ + TypeX: { + id: "string", + "+": "reject" + }, + // always worked + TypeA: { + label: "string", + id: "string", + "result?": "TypeA | TypeX", + "+": "reject" + }, + // previously did not work + TypeB: { + label: "string", + id: "string", + "result?": "TypeB | TypeX | null", + "+": "reject" + }, + // always worked + TypeC: { + label: "string", + id: "string", + "result?": "TypeA | TypeX | null", + "+": "reject" + } + }) + const types = ruleset.export() + + const data = { + label: "hi", + id: "C", + result: { label: "A", id: "B" } + } + + attest(types.TypeA(data)).equals(data) + // previously resulted in error: + // result.label must be removed + attest(types.TypeB(data)).equals(data) + attest(types.TypeC(data)).equals(data) + }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7fda72b41f..9cab8f6e63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,9 @@ importers: '@ark/fs': specifier: workspace:* version: link:../fs + '@ark/schema': + specifier: workspace:* + version: link:../schema '@ark/util': specifier: workspace:* version: link:../util