diff --git a/packages/schema/src/checks.ts b/packages/schema/src/checks.ts index e52465349..e4b61604a 100644 --- a/packages/schema/src/checks.ts +++ b/packages/schema/src/checks.ts @@ -224,39 +224,6 @@ export function maskedAnyCheck(mask: number, checks: readonly Check[]): Check { return (v) => (typeBits(v) & mask) !== 0 || rest(v); } -/** A bigint within the bounds (bigint `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`). */ -export function bigintCheck(bounds: BigintBounds): Check { - const { minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf } = bounds; - return (v) => - typeof v === "bigint" && - (minimum === undefined || v >= minimum) && - (maximum === undefined || v <= maximum) && - (exclusiveMinimum === undefined || v > exclusiveMinimum) && - (exclusiveMaximum === undefined || v < exclusiveMaximum) && - (multipleOf === undefined || multipleOf === 0n || v % multipleOf === 0n); -} - -/** A `bigint` schema's bounds: each keyword when its value is a bigint, as `Type.BigInt` writes them. */ -export type BigintBounds = { - readonly minimum: bigint | undefined; - readonly maximum: bigint | undefined; - readonly exclusiveMinimum: bigint | undefined; - readonly exclusiveMaximum: bigint | undefined; - readonly multipleOf: bigint | undefined; -}; - -/** The bigint bounds of `node`. */ -export function bigintBounds(node: Readonly>): BigintBounds { - const value = (key: string) => (typeof node[key] === "bigint" ? (node[key] as bigint) : undefined); - return { - minimum: value("minimum"), - maximum: value("maximum"), - exclusiveMinimum: value("exclusiveMinimum"), - exclusiveMaximum: value("exclusiveMaximum"), - multipleOf: value("multipleOf"), - }; -} - /** A Uint8Array within the length bounds. */ export function bytesCheck(min: number, max: number): Check { return (v) => v instanceof Uint8Array && v.length >= min && v.length <= max; @@ -308,26 +275,42 @@ export function stringCheck( }; } -/** The number keywords. `mode`: 0 passes other types, 1 requires a number, 2 an integer. */ +/** A number bound: a number, or a bigint (TypeBox's `Type.BigInt` writes its bounds as bigints). */ +export type Bound = number | bigint; + +/** + * The number keywords, for numbers and bigints alike (as TypeBox applies them): bounds of either kind compare with + * values of either kind. `mode`: 0 passes other types, 1 requires a number, 2 an integer, 3 a bigint. + */ export function numberCheck( - mode: 0 | 1 | 2, - min: number, - max: number, - xmin: number, - xmax: number, - multipleOf: number | undefined, + mode: 0 | 1 | 2 | 3, + min: Bound, + max: Bound, + xmin: Bound, + xmax: Bound, + multipleOf: Bound | undefined, ): Check { const check: Check = (v) => { - if (typeof v !== "number") return mode === 0; + if (typeof v !== "number" && typeof v !== "bigint") return mode === 0; + if (mode === 3 ? typeof v !== "bigint" : mode !== 0 && typeof v !== "number") return false; if (mode === 2 && !Number.isInteger(v)) return false; return v >= min && v <= max && v > xmin && v < xmax && (multipleOf === undefined || isMultiple(v, multipleOf)); }; - if (mode === 0 || multipleOf !== undefined) return check; + const plain = + typeof min === "number" && typeof max === "number" && typeof xmin === "number" && typeof xmax === "number"; + if (mode === 0 || mode === 3 || multipleOf !== undefined || !plain) return check; return leaf(mode === 2 ? 10 : 11, new Bounds(min, max, xmin, xmax), check); } -/** Whether `v` is a multiple of `d`, exactly for decimals: 0.0075 / 0.0001 is 74.99999999999999 in floating point. */ -export function isMultiple(v: number, d: number): boolean { +/** + * Whether `v` is a multiple of `d`, exactly for decimals: 0.0075 / 0.0001 is 74.99999999999999 in floating point. With + * a bigint on either side, exact integer arithmetic; a bigint is no multiple of 0 or of a fraction. + */ +export function isMultiple(v: Bound, d: Bound): boolean { + if (typeof v === "bigint" || typeof d === "bigint") { + if (!Number.isInteger(Number(v)) || !Number.isInteger(Number(d)) || BigInt(d) === 0n) return false; + return BigInt(v) % BigInt(d) === 0n; + } const q = v / d; if (Number.isInteger(q)) return true; if (!Number.isFinite(q)) return v % d === 0; diff --git a/packages/schema/src/compiler.ts b/packages/schema/src/compiler.ts index 1899c4878..5894091f9 100644 --- a/packages/schema/src/compiler.ts +++ b/packages/schema/src/compiler.ts @@ -24,8 +24,7 @@ import { ALWAYS, and, anyCheck, - bigintBounds, - bigintCheck, + type Bound, bytesCheck, constCheck, containsCheck, @@ -148,14 +147,12 @@ export class Compiler { if (single === "bytes") { parts.push(bytesCheck(number(node.minLength) ?? 0, number(node.maxLength) ?? Number.POSITIVE_INFINITY)); typeInGroup = true; - } else if (single === "bigint") { - parts.push(bigintCheck(bigintBounds(node))); - typeInGroup = true; } else { const string = this.stringGroup(node, single === "string"); if (string !== undefined) parts.push(string); - const numeric = single === "number" || single === "integer"; - const number = this.numberGroup(node, single === "number" ? 1 : single === "integer" ? 2 : 0); + const numeric = single === "number" || single === "integer" || single === "bigint"; + const mode = single === "number" ? 1 : single === "integer" ? 2 : single === "bigint" ? 3 : 0; + const number = this.numberGroup(node, mode); if (number !== undefined) parts.push(number); const object = this.objectGroup(node, base, single === "object"); if (object !== undefined) parts.push(object); @@ -288,12 +285,12 @@ export class Compiler { return stringCheck(strict, min, max, re, format); } - private numberGroup(node: SchemaObject, mode: 0 | 1 | 2): Check | undefined { - const min = number(node.minimum); - const max = number(node.maximum); - const xmin = number(node.exclusiveMinimum); - const xmax = number(node.exclusiveMaximum); - const multipleOf = number(node.multipleOf); + private numberGroup(node: SchemaObject, mode: 0 | 1 | 2 | 3): Check | undefined { + const min = bound(node.minimum); + const max = bound(node.maximum); + const xmin = bound(node.exclusiveMinimum); + const xmax = bound(node.exclusiveMaximum); + const multipleOf = bound(node.multipleOf); if ([min, max, xmin, xmax, multipleOf].every((x) => x === undefined)) return undefined; const inf = Number.POSITIVE_INFINITY; return numberCheck(mode, min ?? -inf, max ?? inf, xmin ?? -inf, xmax ?? inf, multipleOf); @@ -617,6 +614,11 @@ export function requiredKeys(node: SchemaObject, properties: SchemaObject): stri }); } +/** A number keyword's value: a number, or a bigint (TypeBox's `Type.BigInt` bounds). */ +function bound(value: unknown): Bound | undefined { + return typeof value === "number" || typeof value === "bigint" ? value : undefined; +} + function number(value: unknown): number | undefined { return typeof value === "number" ? value : undefined; } diff --git a/packages/schema/src/explain.ts b/packages/schema/src/explain.ts index 463ecb8e3..539960e82 100644 --- a/packages/schema/src/explain.ts +++ b/packages/schema/src/explain.ts @@ -3,7 +3,7 @@ import { newAnnotations } from "./annotations.ts"; import { - bigintBounds, + type Bound, codePoints, constCheck, containsCheck, @@ -83,8 +83,7 @@ export class Explainer { push("const", `expected ${JSON.stringify(node.const)}`); if (typeof v === "string" && node.type !== "bytes") this.walkString(node, v, push); if (v instanceof Uint8Array && node.type === "bytes") this.walkBytes(node, v, push); - if (typeof v === "number") this.walkNumber(node, v, push); - if (typeof v === "bigint" && node.type === "bigint") this.walkBigint(node, v, push); + if (typeof v === "number" || typeof v === "bigint") this.walkNumber(node, v, push); if (isObject(v)) this.walkObject(node, b, v, path, out, push); if (Array.isArray(v)) this.walkArray(node, b, v, path, out, push); if (Array.isArray(node.allOf)) for (const s of node.allOf) this.walk(s, b, v, path, out); @@ -159,31 +158,16 @@ export class Explainer { } } - private walkNumber(node: SchemaObject, v: number, push: Push): void { - if (typeof node.minimum === "number" && v < node.minimum) push("minimum", `expected >= ${node.minimum}`); - if (typeof node.maximum === "number" && v > node.maximum) push("maximum", `expected <= ${node.maximum}`); - if (typeof node.exclusiveMinimum === "number" && v <= node.exclusiveMinimum) { - push("exclusiveMinimum", `expected > ${node.exclusiveMinimum}`); - } - if (typeof node.exclusiveMaximum === "number" && v >= node.exclusiveMaximum) { - push("exclusiveMaximum", `expected < ${node.exclusiveMaximum}`); - } - if (typeof node.multipleOf === "number" && !isMultiple(v, node.multipleOf)) { - push("multipleOf", `expected a multiple of ${node.multipleOf}`); - } - } - - private walkBigint(node: SchemaObject, v: bigint, push: Push): void { - const { minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf } = bigintBounds(node); - if (minimum !== undefined && v < minimum) push("minimum", `expected >= ${minimum}`); - if (maximum !== undefined && v > maximum) push("maximum", `expected <= ${maximum}`); - if (exclusiveMinimum !== undefined && v <= exclusiveMinimum) + // Numbers and bigints alike: bounds of either kind compare with values of either kind. + private walkNumber(node: SchemaObject, v: Bound, push: Push): void { + const { minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf } = node; + if (isBound(minimum) && v < minimum) push("minimum", `expected >= ${minimum}`); + if (isBound(maximum) && v > maximum) push("maximum", `expected <= ${maximum}`); + if (isBound(exclusiveMinimum) && v <= exclusiveMinimum) push("exclusiveMinimum", `expected > ${exclusiveMinimum}`); - if (exclusiveMaximum !== undefined && v >= exclusiveMaximum) + if (isBound(exclusiveMaximum) && v >= exclusiveMaximum) push("exclusiveMaximum", `expected < ${exclusiveMaximum}`); - if (multipleOf !== undefined && multipleOf !== 0n && v % multipleOf !== 0n) { - push("multipleOf", `expected a multiple of ${multipleOf}`); - } + if (isBound(multipleOf) && !isMultiple(v, multipleOf)) push("multipleOf", `expected a multiple of ${multipleOf}`); } private walkObject( @@ -392,6 +376,10 @@ function containsMessage(min: number, max: number): string { return `expected between ${min} and ${max} matching items`; } +function isBound(value: unknown): value is Bound { + return typeof value === "number" || typeof value === "bigint"; +} + function pointer(path: string, key: string | number): string { return `${path}/${String(key).replace(/~/g, "~0").replace(/\//g, "~1")}`; }