diff --git a/dev/configs/.changeset/chilled-beans-film.md b/dev/configs/.changeset/chilled-beans-film.md new file mode 100644 index 0000000000..11c568e077 --- /dev/null +++ b/dev/configs/.changeset/chilled-beans-film.md @@ -0,0 +1,12 @@ +--- +"arktype": patch +--- + +## add a syntax error when defining an expression with multiple right bounds + +Ensures expressions like the following result in a syntax error during type validation (will currently not throw at runtime): + +```ts +// Type Error: `Expression 'number' must have at most one right bound` +const boundedNumber = type("number>0<=200") +``` diff --git a/dev/configs/.changeset/fast-chicken-train.md b/dev/configs/.changeset/fast-chicken-train.md new file mode 100644 index 0000000000..87a7a87c4c --- /dev/null +++ b/dev/configs/.changeset/fast-chicken-train.md @@ -0,0 +1,15 @@ +--- +"arktype": patch +--- + +## fix array validation in strict and distilled modes + +Previously, attempting to validate an array with "keys" set to "distilled" or "strict" would yield incorrect results. + +Now, types like this behave as expected: + +```ts +const strictArray = type("string[]", { keys: "strict" }) +// data = ["foo", "bar"] +const { data, problems } = strictArray(["foo", "bar"]) +``` diff --git a/dev/test/keyTraversal.test.ts b/dev/test/keyTraversal.test.ts index bedd81a2b0..f57c101dbb 100644 --- a/dev/test/keyTraversal.test.ts +++ b/dev/test/keyTraversal.test.ts @@ -40,6 +40,22 @@ describe("key traversal", () => { attest(t({ a: "ok" }).data).equals({ a: "ok" }) attest(t(getExtraneousB()).data).snap({ a: "ok" }) }) + it("distilled array", () => { + const o = type( + { a: "email[]" }, + { + keys: "distilled" + } + ) + attest(o({ a: ["shawn@arktype.io"] }).data).snap({ + a: ["shawn@arktype.io"] + }) + attest(o({ a: ["notAnEmail"] }).problems?.summary).snap( + "a/0 must be a valid email (was 'notAnEmail')" + ) + // can handle missing keys + attest(o({ b: ["shawn"] }).problems?.summary).snap("a must be defined") + }) it("distilled union", () => { const o = type([{ a: "string" }, "|", { b: "boolean" }], { keys: "distilled" @@ -78,4 +94,19 @@ describe("key traversal", () => { attest(t({ a: "ok" }).data).equals({ a: "ok" }) attest(t(getExtraneousB()).problems?.summary).snap("b must be removed") }) + it("strict array", () => { + const o = type( + { a: "string[]" }, + { + keys: "strict" + } + ) + attest(o({ a: ["shawn"] }).data).snap({ a: ["shawn"] }) + attest(o({ a: [2] }).problems?.summary).snap( + "a/0 must be a string (was number)" + ) + attest(o({ b: ["shawn"] }).problems?.summary).snap( + "b must be removed\na must be defined" + ) + }) }) diff --git a/dev/test/range.test.ts b/dev/test/range.test.ts index bcfd78a3eb..0d1919f5c3 100644 --- a/dev/test/range.test.ts +++ b/dev/test/range.test.ts @@ -1,6 +1,7 @@ import { describe, it } from "mocha" import { type } from "../../src/main.js" import type { ResolvedNode } from "../../src/nodes/node.js" +import { writeDoubleRightBoundMessage } from "../../src/parse/ast/bound.js" import { writeMultipleLeftBoundsMessage, writeOpenRangeMessage, @@ -210,6 +211,12 @@ describe("range", () => { "Error: the range bounded by >=3 and <2 is empty" ) }) + it("double right bound", () => { + // @ts-expect-error + attest(() => type("number>0<=200")).type.errors( + writeDoubleRightBoundMessage("'number'") + ) + }) }) }) }) diff --git a/src/parse/ast/bound.ts b/src/parse/ast/bound.ts index afaef53125..e354e789ee 100644 --- a/src/parse/ast/bound.ts +++ b/src/parse/ast/bound.ts @@ -1,6 +1,7 @@ import type { SizedData } from "../../utils/data.js" import type { error, isAny } from "../../utils/generics.js" import type { NumberLiteral } from "../../utils/numericLiterals.js" +import type { Scanner } from "../string/shift/scanner.js" import type { inferAst, validateAst } from "./ast.js" import type { astToString } from "./utils.js" @@ -20,10 +21,20 @@ import type { astToString } from "./utils.js" */ export type validateBound = l extends NumberLiteral ? validateAst + : l extends [infer leftAst, Scanner.Comparator, unknown] + ? error>> : isBoundable> extends true ? validateAst : error>> +export const writeDoubleRightBoundMessage = ( + root: root +): writeDoubleRightBoundMessage => + `Expression ${root} must have at most one right bound` + +type writeDoubleRightBoundMessage = + `Expression ${root} must have at most one right bound` + type isBoundable = isAny extends true ? false : [data] extends [SizedData] diff --git a/src/traverse/traverse.ts b/src/traverse/traverse.ts index 54e8d3d8fb..1cdb12e059 100644 --- a/src/traverse/traverse.ts +++ b/src/traverse/traverse.ts @@ -24,6 +24,7 @@ import { domainOf, hasDomain } from "../utils/domains.js" import { throwInternalError } from "../utils/errors.js" import type { extend, stringKeyOf, xor } from "../utils/generics.js" import { hasKey, objectKeysOf } from "../utils/generics.js" +import { wellFormedIntegerMatcher } from "../utils/numericLiterals.js" import type { DefaultObjectKind } from "../utils/objectKinds.js" import { getPath, Path } from "../utils/paths.js" import type { ProblemCode, ProblemOptions, ProblemWriters } from "./problems.js" @@ -245,6 +246,8 @@ const createPropChecker = delete remainingUnseenRequired[k] } else if (props.optional[k]) { isValid = state.traverseKey(k, props.optional[k]) && isValid + } else if (props.index && wellFormedIntegerMatcher.test(k)) { + isValid = state.traverseKey(k, props.index) && isValid } else if (kind === "distilledProps") { if (state.failFast) { // If we're in a union (i.e. failFast is enabled) in