fix(schema): num no longer takes nan and infinity

This commit is contained in:
Cristina Poncela Cubeiro 2026-11-05 14:20:32 +01:00
parent ccff9876e7
commit 55a5c69145
5 changed files with 34 additions and 8 deletions

View file

@ -17,6 +17,7 @@
### Fixed
- A `number` or `integer` no longer accepts `NaN`, `Infinity` or `-Infinity`, as in TypeBox; before, an unbounded `num()` accepted them. JSON has neither, so only values built in code are affected. Errors name the value (`expected number, got NaN`).
- The third-party licenses are in `THIRD_PARTY_NOTICES/`, renamed from `LICENSES/`, which Vite's license notices read as the package's license file, failing browser builds that bundle the package.
- A required property set to `undefined` is valid when its schema takes `undefined` (a union with `Type.Undefined()`, `Type.Unknown()`, `Type.Any()`), as in TypeBox; before, it was always missing. An optional one set to `undefined` still counts as absent.
- Fixed local `$ref`s in schemas whose root `$id` is a relative path with a directory, such as `schemas/tool.json`: the `$id` was applied twice, so `compile` threw and `unresolvedRefs: "reject"` rejected valid tool arguments. Relative `$id`s are now also applied once with `unevaluatedProperties`/`unevaluatedItems`, when explaining errors under nested resources, and on the first coercion of a schema with `$dynamicRef` ([#11697](https://github.com/earendil-works/pi/issues/11697))

View file

@ -7,7 +7,7 @@ import type { Check } from "./types.ts";
export const ALWAYS: Check = () => true;
export const NEVER: Check = () => false;
const IS_STRING: Check = (v) => typeof v === "string";
const IS_NUMBER: Check = (v) => typeof v === "number";
const IS_NUMBER: Check = (v) => Number.isFinite(v);
const IS_INTEGER: Check = (v) => Number.isInteger(v);
const IS_BOOLEAN: Check = (v) => typeof v === "boolean";
const IS_NULL: Check = (v) => v === null;
@ -24,7 +24,8 @@ export function isObject(v: unknown): v is Record<string, unknown> {
/**
* The check of a `type` name. `bytes` (a Uint8Array), `bigint` (TypeBox's `Type.BigInt`) and `undefined` (TypeBox's
* `Type.Undefined`) extend JSON Schema for values that never pass through JSON.
* `Type.Undefined`) extend JSON Schema for values that never pass through JSON. A `number` is finite, as in TypeBox:
* JSON has no NaN or Infinity, so only a value built in code can be one.
*/
export const TYPE_CHECKS: ReadonlyMap<string, Check> = new Map([
["string", IS_STRING],
@ -85,7 +86,7 @@ function leafOk(code: number, x: unknown, arg: unknown): boolean {
case 1:
return typeof x === "string";
case 2:
return typeof x === "number";
return Number.isFinite(x);
case 3:
return Number.isInteger(x);
case 4:
@ -104,7 +105,7 @@ function leafOk(code: number, x: unknown, arg: unknown): boolean {
case 10:
return Number.isInteger(x) && inBounds(x as number, arg as Bounds);
case 11:
return typeof x === "number" && inBounds(x, arg as Bounds);
return Number.isFinite(x) && inBounds(x as number, arg as Bounds);
case 12:
return typeof x === "string" && lengthOk(x, (arg as Bounds).min, (arg as Bounds).max);
case 13:
@ -193,13 +194,13 @@ export function typeMask(types: readonly unknown[]): number {
return mask;
}
/** The type bits of `v`: an integer has the bits of `number` and `integer`. */
/** The type bits of `v`: an integer has the bits of `number` and `integer`; NaN and Infinity have neither. */
function typeBits(v: unknown): number {
switch (typeof v) {
case "string":
return 1;
case "number":
return Number.isInteger(v) ? 6 : 2;
return Number.isInteger(v) ? 6 : Number.isFinite(v) ? 2 : 0;
case "boolean":
return 8;
case "object":
@ -292,7 +293,7 @@ export function numberCheck(
): Check {
const check: Check = (v) => {
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 === 3 ? typeof v !== "bigint" : mode !== 0 && !Number.isFinite(v)) return false;
if (mode === 2 && !Number.isInteger(v)) return false;
return v >= min && v <= max && v > xmin && v < xmax && (multipleOf === undefined || isMultiple(v, multipleOf));
};

View file

@ -535,7 +535,7 @@ function typesOf(schema: SchemaObject): string[] {
function matchesType(value: unknown, type: string): boolean {
switch (type) {
case "number":
return typeof value === "number";
return Number.isFinite(value);
case "integer":
return typeof value === "number" && Number.isInteger(value);
case "boolean":

View file

@ -394,5 +394,6 @@ function typeName(v: unknown): string {
if (v instanceof Uint8Array) return "bytes";
if (typeof v === "bigint") return "bigint";
if (typeof v === "number" && Number.isInteger(v)) return "integer";
if (typeof v === "number" && !Number.isFinite(v)) return String(v);
return typeof v;
}

View file

@ -1,6 +1,7 @@
// compile() and Validator: checks, errors, values, options and schemas from other sources.
import Type from "typebox";
import { Check } from "typebox/value";
import { describe, expect, it } from "vitest";
import { formats } from "../src/formats/index.ts";
import { compile } from "../src/index.ts";
@ -109,6 +110,28 @@ describe("values", () => {
expect(compile({ type: "constructor" }).check(1)).toBe(false);
});
it("rejects NaN and Infinity as a number or an integer, with or without bounds, as TypeBox does", () => {
const schemas = [
{ type: "number" },
{ type: "integer" },
{ type: "number", minimum: 0 },
{ type: ["number", "string"] },
{ anyOf: [{ type: "number" }, { type: "null" }] },
{ type: "object", properties: { a: { type: "number" } } },
];
for (const schema of schemas) {
const { check } = compile(schema);
for (const v of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) {
const value = schema.type === "object" ? { a: v } : v;
expect(check(value), `${JSON.stringify(schema)}: ${v}`).toBe(false);
expect(check(value)).toBe(Check(schema, value));
}
}
expect(compile({ type: "number" }).errors(Number.NaN)).toEqual([
{ path: "", keyword: "type", message: "expected number, got NaN" },
]);
});
it("checks a detached check function", () => {
const { check } = compile(read);
expect([{ path: "a" }, { path: 1 }].filter(check)).toEqual([{ path: "a" }]);