From 79c2b276c3645ea51e7bae8fe4463f2f39ddabc8 Mon Sep 17 00:00:00 2001 From: David Blass Date: Fri, 24 May 2024 17:57:09 -0400 Subject: [PATCH] fix consecutive narrow inference, nested pipes (#971) --- .changeset/healthy-numbers-play.md | 14 ++++++ .changeset/little-icons-impress.md | 5 +++ ark/attest/__tests__/snapPopulation.test.ts | 4 +- ark/schema/ast.ts | 11 +++++ ark/schema/node.ts | 11 ++++- ark/schema/predicate.ts | 6 +-- ark/schema/roots/intersection.ts | 2 +- ark/schema/roots/morph.ts | 40 +++++++++++------ ark/schema/roots/root.ts | 48 +++++++++++++++------ ark/schema/roots/union.ts | 19 ++++---- ark/schema/shared/traversal.ts | 12 +++--- ark/type/CHANGELOG.md | 45 +++++++++++++++++++ ark/type/__tests__/narrow.test.ts | 43 +++++++++++++++++- ark/type/__tests__/pipe.test.ts | 39 ++++++++++++++++- ark/type/__tests__/realWorld.test.ts | 31 +++++++++++++ ark/type/__tests__/traverse.test.ts | 4 +- ark/type/package.json | 2 +- ark/type/type.ts | 8 ++-- ark/util/package.json | 3 -- 19 files changed, 285 insertions(+), 62 deletions(-) create mode 100644 .changeset/healthy-numbers-play.md create mode 100644 .changeset/little-icons-impress.md create mode 100644 ark/type/CHANGELOG.md diff --git a/.changeset/healthy-numbers-play.md b/.changeset/healthy-numbers-play.md new file mode 100644 index 0000000000..222129fab9 --- /dev/null +++ b/.changeset/healthy-numbers-play.md @@ -0,0 +1,14 @@ +--- +"@arktype/util": patch +--- + +Provide recommended tsconfig via `tsconfig.base.json`, e.g.: + +`tsconfig.json` + +```ts +{ + "extends": "@arktype/util/tsconfig.base.json", + // your settings here +} +``` diff --git a/.changeset/little-icons-impress.md b/.changeset/little-icons-impress.md new file mode 100644 index 0000000000..abeeb173e7 --- /dev/null +++ b/.changeset/little-icons-impress.md @@ -0,0 +1,5 @@ +--- +"@arktype/schema": patch +--- + +Pipe and narrow bug fixes (see [arktype CHANGELOG](../type/CHANGELOG.md)) diff --git a/ark/attest/__tests__/snapPopulation.test.ts b/ark/attest/__tests__/snapPopulation.test.ts index 2b8bea9eee..6e152c865a 100644 --- a/ark/attest/__tests__/snapPopulation.test.ts +++ b/ark/attest/__tests__/snapPopulation.test.ts @@ -10,7 +10,7 @@ contextualize(() => { fromHere("benchExpectedOutput.ts") ).replaceAll("\r\n", "\n") equal(actual, expectedOutput) - }).timeout(10000) + }).timeout(20000) it("snap populates file", () => { const actual = runThenGetContents(fromHere("snapTemplate.ts")) @@ -18,5 +18,5 @@ contextualize(() => { fromHere("snapExpectedOutput.ts") ).replaceAll("\r\n", "\n") equal(actual, expectedOutput) - }).timeout(10000) + }).timeout(20000) }) diff --git a/ark/schema/ast.ts b/ark/schema/ast.ts index d7de567c68..26fd70567b 100644 --- a/ark/schema/ast.ts +++ b/ark/schema/ast.ts @@ -226,6 +226,17 @@ export type constrain< t, kind extends PrimitiveConstraintKind, schema extends NodeSchema +> = + _constrain extends infer constrained ? + [t, constrained] extends [constrained, t] ? + t + : constrained + : never + +type _constrain< + t, + kind extends PrimitiveConstraintKind, + schema extends NodeSchema > = schemaToConstraint extends infer constraint ? t extends of ? diff --git a/ark/schema/node.ts b/ark/schema/node.ts index b2d95295c0..86bff9cace 100644 --- a/ark/schema/node.ts +++ b/ark/schema/node.ts @@ -41,6 +41,7 @@ import { type TraverseAllows, type TraverseApply } from "./shared/traversal.js" +import type { arkKind } from "./shared/utils.js" export type UnknownNode = BaseNode | Root @@ -117,13 +118,13 @@ export abstract class BaseNode< // decorator from @arktype/util on these for now // as they cause a deopt in V8 private _in?: BaseNode; - get in(): BaseNode { + get in(): this extends { [arkKind]: "root" } ? BaseRoot : BaseNode { this._in ??= this.getIo("in") return this._in as never } private _out?: BaseNode - get out(): BaseNode { + get out(): this extends { [arkKind]: "root" } ? BaseRoot : BaseNode { this._out ??= this.getIo("out") return this._out as never } @@ -168,6 +169,12 @@ export abstract class BaseNode< return this.typeHash === other.typeHash } + assertHasKind(kind: kind): Node { + if (!this.kind === (kind as never)) + throwError(`${this.kind} node was not of asserted kind ${kind}`) + return this as never + } + hasKind(kind: kind): this is Node { return this.kind === (kind as never) } diff --git a/ark/schema/predicate.ts b/ark/schema/predicate.ts index 94fc37b7a5..3b1517e1a3 100644 --- a/ark/schema/predicate.ts +++ b/ark/schema/predicate.ts @@ -97,9 +97,9 @@ export type PredicateCast = ( ctx: TraversalContext ) => input is narrowed -export type inferNarrow = +export type inferNarrow = predicate extends (data: any, ...args: any[]) => data is infer narrowed ? - In extends of ? + t extends of ? constrain, "predicate", any> : constrain - : constrain + : constrain diff --git a/ark/schema/roots/intersection.ts b/ark/schema/roots/intersection.ts index a3f3b0d4ce..0f6f4430db 100644 --- a/ark/schema/roots/intersection.ts +++ b/ark/schema/roots/intersection.ts @@ -329,7 +329,7 @@ export const intersectionImplementation: nodeImplementationOf child.description).join(" and "), expected: source => ` • ${source.errors.map(e => e.expected).join("\n • ")}`, - problem: ctx => `must be...\n${ctx.expected}` + problem: ctx => `${ctx.actual} must be...\n${ctx.expected}` }, intersections: { intersection: (l, r, ctx) => intersectIntersections(l, r, ctx), diff --git a/ark/schema/roots/morph.ts b/ark/schema/roots/morph.ts index 24a2358a3e..ff16845afc 100644 --- a/ark/schema/roots/morph.ts +++ b/ark/schema/roots/morph.ts @@ -141,8 +141,8 @@ export class MorphNode extends BaseRoot { this.in.traverseAllows(data, ctx) traverseApply: TraverseApply = (data, ctx) => { - ctx.queueMorphs(this.morphs) this.in.traverseApply(data, ctx) + ctx.queueMorphs(this.morphs) } expression = `(In: ${this.in.expression}) => Out<${this.out?.expression ?? "unknown"}>` @@ -152,20 +152,20 @@ export class MorphNode extends BaseRoot { js.return(js.invoke(this.in)) return } - js.line(`ctx.queueMorphs(${this.compiledMorphs})`) js.line(js.invoke(this.in)) + js.line(`ctx.queueMorphs(${this.compiledMorphs})`) } override get in(): BaseRoot { return this.inner.in } - get validatedOut(): BaseRoot | undefined { - const lastMorph = this.inner.morphs.at(-1) - return hasArkKind(lastMorph, "root") ? - (lastMorph?.out as BaseRoot) - : undefined - } + lastMorph = this.inner.morphs.at(-1) + validatedOut: BaseRoot | undefined = + hasArkKind(this.lastMorph, "root") ? + Object.assign(this.referencesById, this.lastMorph.out.referencesById) && + this.lastMorph.out + : undefined override get out(): BaseRoot { return this.validatedOut ?? this.$.keywords.unknown.raw @@ -191,24 +191,38 @@ export type inferMorphOut = Exclude< > export type distillIn = - includesMorphs extends true ? _distill : t + includesMorphsOrConstraints extends true ? _distill : t export type distillOut = - includesMorphs extends true ? _distill : t + includesMorphsOrConstraints extends true ? _distill : t export type distillConstrainableIn = - includesMorphs extends true ? _distill : t + includesMorphsOrConstraints extends true ? + _distill + : t export type distillConstrainableOut = - includesMorphs extends true ? _distill : t + includesMorphsOrConstraints extends true ? + _distill + : t -export type includesMorphs = +export type includesMorphsOrConstraints = [t, _distill, t, _distill] extends ( [_distill, t, _distill, t] ) ? false : true +export type includesMorphs = + [ + _distill, + _distill + ] extends ( + [_distill, _distill] + ) ? + false + : true + type _distill< t, io extends "in" | "out", diff --git a/ark/schema/roots/root.ts b/ark/schema/roots/root.ts index 360a20c364..aaf9f2a6d7 100644 --- a/ark/schema/roots/root.ts +++ b/ark/schema/roots/root.ts @@ -168,7 +168,7 @@ export abstract class BaseRoot< return this.configure(description) } - create(input: unknown): unknown { + from(input: unknown): unknown { // ideally we wouldn't validate here but for now we need to do determine // which morphs to apply return this.assert(input) @@ -179,8 +179,11 @@ export abstract class BaseRoot< } private pipeOnce(morph: Morph): BaseRoot { - if (hasArkKind(morph, "root")) - return pipeNodesRoot(this, morph, this.$) as never + if (hasArkKind(morph, "root")) { + const result = pipeNodesRoot(this, morph, this.$) + if (result instanceof Disjoint) return result.throw() + return result as BaseRoot + } if (this.hasKind("union")) { const branches = this.branches.map(node => node.pipe(morph)) return this.$.node("union", { ...this.inner, branches }) @@ -198,15 +201,30 @@ export abstract class BaseRoot< } narrow(predicate: Predicate): BaseRoot { - return this.constrain("predicate", predicate) + return this.constrainOut("predicate", predicate) } constrain( kind: kind, schema: NodeSchema + ): BaseRoot { + return this._constrain("in", kind, schema) + } + + constrainOut( + kind: kind, + schema: NodeSchema + ): BaseRoot { + return this._constrain("out", kind, schema) + } + + private _constrain( + io: "in" | "out", + kind: PrimitiveConstraintKind, + schema: any ): BaseRoot { const constraint = this.$.node(kind, schema) - if (constraint.impliedBasis && !this.extends(constraint.impliedBasis)) { + if (constraint.impliedBasis && !this[io].extends(constraint.impliedBasis)) { return throwInvalidOperandError( kind, constraint.impliedBasis as never, @@ -214,12 +232,18 @@ export abstract class BaseRoot< ) } - return this.and( - // TODO: not an intersection - this.$.node("intersection", { - [kind]: constraint - }) - ) + const partialIntersection = this.$.node("intersection", { + [kind]: constraint + }) + + const result = + io === "in" ? + intersectNodesRoot(this, partialIntersection, this.$) + : pipeNodesRoot(this, partialIntersection, this.$) + + if (result instanceof Disjoint) result.throw() + + return result as never } onUndeclaredKey(undeclared: UndeclaredKeyBehavior): BaseRoot { @@ -331,7 +355,7 @@ export declare abstract class InnerRoot extends Callable< onUndeclaredKey(behavior: UndeclaredKeyBehavior): this - create(literal: this["inferIn"]): this["infer"] + from(literal: this["inferIn"]): this["infer"] } // this is declared as a class internally so we can ensure all "abstract" diff --git a/ark/schema/roots/union.ts b/ark/schema/roots/union.ts index d25242c31c..1dbc47336f 100644 --- a/ark/schema/roots/union.ts +++ b/ark/schema/roots/union.ts @@ -320,7 +320,7 @@ export class UnionNode extends BaseRoot { const l = this.branches[lIndex] for (let rIndex = lIndex + 1; rIndex < this.branches.length; rIndex++) { const r = this.branches[rIndex] - const result = intersectNodesRoot(l, r, l.$) + const result = intersectNodesRoot(l.in, r.in, l.$) if (!(result instanceof Disjoint)) continue for (const { path, kind, disjoint } of result.flat) { @@ -523,18 +523,17 @@ export const reduceBranches = ({ continue } const intersection = intersectNodesRoot( - branches[i], - branches[j], + branches[i].in, + branches[j].in, branches[0].$ - ) + )! if (intersection instanceof Disjoint) continue - if (intersection.equals(branches[i])) { - if (!ordered) { - // preserve ordered branches that are a subtype of a subsequent branch - uniquenessByIndex[i] = false - } - } else if (intersection.equals(branches[j])) uniquenessByIndex[j] = false + if (intersection.equals(branches[i].in)) { + // preserve ordered branches that are a subtype of a subsequent branch + uniquenessByIndex[i] = !!ordered + } else if (intersection.equals(branches[j].in)) + uniquenessByIndex[j] = false } } return branches.filter((_, i) => uniquenessByIndex[i]) diff --git a/ark/schema/shared/traversal.ts b/ark/schema/shared/traversal.ts index 4e93171c94..42f42deb67 100644 --- a/ark/schema/shared/traversal.ts +++ b/ark/schema/shared/traversal.ts @@ -49,7 +49,6 @@ export class TraversalContext { finalize(): unknown { if (this.hasError()) return this.errors - let out: any = this.root if (this.queuedMorphs.length) { for (let i = 0; i < this.queuedMorphs.length; i++) { const { path, morphs } = this.queuedMorphs[i] @@ -60,14 +59,17 @@ export class TraversalContext { if (key !== undefined) { // find the object on which the key to be morphed exists - parent = out + parent = this.root for (let pathIndex = 0; pathIndex < path.length - 1; pathIndex++) parent = parent[path[pathIndex]] } this.path = path for (const morph of morphs) { - const result = morph(parent === undefined ? out : parent[key!], this) + const result = morph( + parent === undefined ? this.root : parent[key!], + this + ) if (result instanceof ArkErrors) return result if (this.hasError()) return this.errors if (result instanceof ArkError) { @@ -79,12 +81,12 @@ export class TraversalContext { // apply the morph function and assign the result to the // corresponding property, or to root if path is empty - if (parent === undefined) out = result + if (parent === undefined) this.root = result else parent[key!] = result } } } - return out + return this.root } get currentErrorCount(): number { diff --git a/ark/type/CHANGELOG.md b/ark/type/CHANGELOG.md new file mode 100644 index 0000000000..321e84cbf5 --- /dev/null +++ b/ark/type/CHANGELOG.md @@ -0,0 +1,45 @@ +# arktype + +## 2.0.0-dev.15 + +- Fix a crash when piping to nested paths (see https://github.com/arktypeio/arktype/issues/968) +- Fix inferred input type of `.narrow` (see https://github.com/arktypeio/arktype/issues/969) +- Throw on a pipe between disjoint types, e.g.: + +```ts +// Now correctly throws ParseError: Intersection of <3 and >5 results in an unsatisfiable type +const t = type("number>5").pipe(type("number<3")) + +// Previously returned a Disjoint object +``` + +- Mention the actual value when describing an intersection error: + +```ts +const evenGreaterThan5 = type({ value: "number%2>5" }) +const out = evenGreaterThan5(3) +if (out instanceof type.errors) { + /* + value 3 must be... + • a multiple of 2 + • at most 5 + */ + console.log(out.summary) +} + +// was previously "value must be..." +``` + +Thanks [@TizzySaurus](https://github.com/TizzySaurus) for reporting the last two on [our Discord](arktype.io/discord)! + +https://github.com/arktypeio/arktype/pull/971 + +## 2.0.0-dev.14 + +### Patch Changes + +- Initial changeset + +``` + +``` diff --git a/ark/type/__tests__/narrow.test.ts b/ark/type/__tests__/narrow.test.ts index 25844e80d0..c8e242fe0d 100644 --- a/ark/type/__tests__/narrow.test.ts +++ b/ark/type/__tests__/narrow.test.ts @@ -1,5 +1,5 @@ import { attest, contextualize } from "@arktype/attest" -import type { Narrowed, Out, of, string } from "@arktype/schema" +import type { Narrowed, Out, number, of, string } from "@arktype/schema" import { registeredReference, type equals } from "@arktype/util" import { type, type Type } from "arktype" @@ -30,6 +30,20 @@ contextualize(() => { attest(divisibleBy3(1).toString()).snap("must be divisible by 3 (was 1)") }) + it("chained narrows", () => { + const divisibleBy30 = type("number") + .narrow((n, ctx) => n % 2 === 0 || ctx.invalid("divisible by 2")) + .narrow((n, ctx) => n % 3 === 0 || ctx.invalid("divisible by 3")) + .narrow((n, ctx) => n % 5 === 0 || ctx.invalid("divisible by 5")) + + attest(divisibleBy30.t) + + attest(divisibleBy30(1).toString()).snap("must be divisible by 2 (was 1)") + attest(divisibleBy30(2).toString()).snap("must be divisible by 3 (was 2)") + attest(divisibleBy30(6).toString()).snap("must be divisible by 5 (was 6)") + attest(divisibleBy30(30)).equals(30) + }) + it("problem at path", () => { const abEqual = type([ { @@ -90,11 +104,36 @@ contextualize(() => { .pipe(s => s.length) .narrow((n): n is 5 => n === 5) - attest Out>, {}>>(t) + const morphRef = t.raw.assertHasKind("morph").serializedMorphs[0] + + const predicateRef = + t.raw.firstReferenceOfKindOrThrow("predicate").serializedPredicate + + attest(t.json).snap({ + in: "string", + morphs: [morphRef, { predicate: [predicateRef] }] + }) + + attest Out>>>(t) + + attest(t("12345")).snap(5) + attest(t("1234").toString()).snap( + "must be valid according to an anonymous predicate (was 4)" + ) }) it("expression", () => { const t = type("string", ":", (s): s is `f${string}` => s[0] === "f") attest<`f${string}`>(t.infer) }) + + // TODO: reenable + // https://github.com/arktypeio/arktype/issues/970 + // it("narrows the output type of an morph within a single type", () => { + // const t = type("string") + // .pipe(s => `${s}!`) + // .narrow((s): s is "foo!" => s === "foo!") + + // attest Out>>>(t) + // }) }) diff --git a/ark/type/__tests__/pipe.test.ts b/ark/type/__tests__/pipe.test.ts index 49c4212b0a..d24daa6bd0 100644 --- a/ark/type/__tests__/pipe.test.ts +++ b/ark/type/__tests__/pipe.test.ts @@ -1,6 +1,6 @@ import { attest, contextualize } from "@arktype/attest" import { assertNodeKind, type Out } from "@arktype/schema" -import { scope, type, type Type } from "arktype" +import { scope, type Type, type } from "arktype" contextualize(() => { it("base", () => { @@ -14,6 +14,12 @@ contextualize(() => { attest(result.toString()).snap("must be a number (was string)") }) + it("disjoint", () => { + attest(() => type("number>5").pipe(type("number<3"))).throws.snap( + "ParseError: Intersection of <3 and >5 results in an unsatisfiable type" + ) + }) + it("within type", () => { const t = type(["boolean", "=>", data => !data]) attest Out>>(t) @@ -136,6 +142,37 @@ contextualize(() => { attest<{ a: number } | type.errors>(out).equals({ a: 4 }) }) + it("doesn't pipe on error", () => { + const a = type({ a: "number" }).pipe(o => o.a + 1) + + const aMorphs = a.raw.assertHasKind("morph").serializedMorphs + + const b = type({ a: "string" }, "=>", o => o.a + "!") + + const bMorphs = b.raw.assertHasKind("morph").serializedMorphs + + const t = b.or(a) + + attest< + Type< + | ((In: { a: string }) => Out) + | ((In: { a: number }) => Out) + > + >(t) + attest(t.json).snap([ + { + in: { required: [{ key: "a", value: "number" }], domain: "object" }, + morphs: aMorphs + }, + { + in: { required: [{ key: "a", value: "string" }], domain: "object" }, + morphs: bMorphs + } + ]) + + attest(t({ a: 2 })).snap(3) + }) + it("in array", () => { const types = scope({ lengthOfString: ["string", "=>", data => data.length], diff --git a/ark/type/__tests__/realWorld.test.ts b/ark/type/__tests__/realWorld.test.ts index ea5ba55b3e..dd1bcb0238 100644 --- a/ark/type/__tests__/realWorld.test.ts +++ b/ark/type/__tests__/realWorld.test.ts @@ -364,4 +364,35 @@ nospace must be matched by ^\\S*$ (was "One space")`) "first_name must be a string or null (was 5)" ) }) + + // https://github.com/arktypeio/arktype/issues/968 + it("handles consecutive pipes", () => { + const MyAssets = scope({ + Asset: { + token: "string", + amount: type("string").pipe((s, ctx) => { + try { + return BigInt(s) + } catch { + return ctx.error("a valid non-decimal number") + } + }) + }, + Assets: { + assets: "Asset[]>=1" + } + }) + .export() + .Assets.pipe(o => { + const assets = o.assets.reduce>((acc, asset) => { + acc[asset.token] = asset.amount + return acc + }, {}) + return { ...o, assets } + }) + + const out = MyAssets({ assets: [{ token: "a", amount: "1" }] }) + + attest(out).snap({ assets: { a: "1n" } }) + }) }) diff --git a/ark/type/__tests__/traverse.test.ts b/ark/type/__tests__/traverse.test.ts index 1447080704..9d5531776b 100644 --- a/ark/type/__tests__/traverse.test.ts +++ b/ark/type/__tests__/traverse.test.ts @@ -132,7 +132,7 @@ contextualize(() => { it("multi", () => { const naturalNumber = type("integer>0") attest(naturalNumber(-1.2).toString()).snap( - `must be... + `-1.2 must be... • an integer • more than 0` ) @@ -140,7 +140,7 @@ contextualize(() => { natural: naturalNumber }) attest(naturalAtPath({ natural: -0.1 }).toString()).snap( - `natural must be... + `natural -0.1 must be... • an integer • more than 0` ) diff --git a/ark/type/package.json b/ark/type/package.json index 0496fdb425..8e044b5df1 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.14", + "version": "2.0.0-dev.15", "license": "MIT", "author": { "name": "David Blass", diff --git a/ark/type/type.ts b/ark/type/type.ts index 7cb448c859..45a2464f70 100644 --- a/ark/type/type.ts +++ b/ark/type/type.ts @@ -13,7 +13,6 @@ import { type ambient, type constrain, type constraintKindOf, - type distillConstrainableOut, type distillIn, type distillOut, type includesMorphs, @@ -208,13 +207,12 @@ declare class _Type g: g ): Type, $> - // TODO: based on below, should maybe narrow morph output if used after - narrow>>( + narrow>>( def: def ): Type< includesMorphs extends true ? - (In: this["tIn"]) => Out> - : inferNarrow, + (In: this["tIn"]) => Out> + : inferNarrow, $ > diff --git a/ark/util/package.json b/ark/util/package.json index 845a1f30cc..0c454bfdd0 100644 --- a/ark/util/package.json +++ b/ark/util/package.json @@ -12,9 +12,6 @@ "exports": { ".": "./out/api.js", "./internal/*": "./out/*", - "./tsconfig": "./tsconfig.base.json", - "./tsconfig.json": "./tsconfig.base.json", - "./tsconfig.base": "./tsconfig.base.json", "./tsconfig.base.json": "./tsconfig.base.json" }, "files": [