From 55a5c6914570e2ae31ca36297654483bbbf46883 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:20:32 +0100 Subject: [PATCH] fix(schema): num no longer takes nan and infinity --- packages/schema/CHANGELOG.md | 1 + packages/schema/src/checks.ts | 15 ++++++++------- packages/schema/src/coerce.ts | 2 +- packages/schema/src/explain.ts | 1 + packages/schema/test/validator.test.ts | 23 +++++++++++++++++++++++ 5 files changed, 34 insertions(+), 8 deletions(-) diff --git a/packages/schema/CHANGELOG.md b/packages/schema/CHANGELOG.md index 9a42d47d2..1876afffa 100644 --- a/packages/schema/CHANGELOG.md +++ b/packages/schema/CHANGELOG.md @@ -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)) diff --git a/packages/schema/src/checks.ts b/packages/schema/src/checks.ts index d119e1fb0..de00b63b0 100644 --- a/packages/schema/src/checks.ts +++ b/packages/schema/src/checks.ts @@ -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 { /** * 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 = 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)); }; diff --git a/packages/schema/src/coerce.ts b/packages/schema/src/coerce.ts index 698c1220d..108e38c81 100644 --- a/packages/schema/src/coerce.ts +++ b/packages/schema/src/coerce.ts @@ -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": diff --git a/packages/schema/src/explain.ts b/packages/schema/src/explain.ts index 805e82d4a..14553a6f6 100644 --- a/packages/schema/src/explain.ts +++ b/packages/schema/src/explain.ts @@ -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; } diff --git a/packages/schema/test/validator.test.ts b/packages/schema/test/validator.test.ts index bab12097c..869eac004 100644 --- a/packages/schema/test/validator.test.ts +++ b/packages/schema/test/validator.test.ts @@ -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" }]);