diff --git a/packages/schema/CHANGELOG.md b/packages/schema/CHANGELOG.md index a7d6b5a3c..01342cf12 100644 --- a/packages/schema/CHANGELOG.md +++ b/packages/schema/CHANGELOG.md @@ -8,7 +8,7 @@ - `recursive(id, build)` builds a schema that contains itself (a tree), with its type given as `T`. - `Validator.validate(value)` returns `{ ok: true, value }` or `{ ok: false, errors }` without coercing, on every validator. - `Static` types TypeBox's `Enum`, `Tuple`, `BigInt` and `Undefined` schemas. -- TypeBox's JavaScript types validate as TypeBox does: `{ type: "bigint" }` (`Type.BigInt`, with bigint bounds) and `{ type: "undefined" }` (`Type.Undefined`; a required property with it may be left out). The `bigint()` builder builds the former. +- TypeBox's JavaScript types validate and coerce as TypeBox does: `{ type: "bigint" }` (`Type.BigInt`) and `{ type: "undefined" }` (`Type.Undefined`), with `Value.Convert`'s rules for both; the number keywords apply to numbers and bigints alike, with bounds of either kind. The `bigint()` builder builds the former. - `equal(a, b)`: JSON equality as `const`, `enum` and `uniqueItems` compare, a drop-in for TypeBox's `Value.Equal`. ### Changed @@ -17,6 +17,7 @@ ### Fixed +- 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)) ## [1.5.0] - 2026-11-02 diff --git a/packages/schema/README.md b/packages/schema/README.md index 9f6596c6a..62b03f899 100644 --- a/packages/schema/README.md +++ b/packages/schema/README.md @@ -129,8 +129,8 @@ import Type from "typebox"; const user = compile(Type.Object({ name: Type.String(), tags: Type.Array(Type.String()) })); ``` -TypeBox's JavaScript-only types (`BigInt`, `Undefined`, `Function`, ...), refinements and codecs are not JSON Schema -and are not supported. +TypeBox's `BigInt` and `Undefined` are supported (see Values). Its other JavaScript-only types (`Function`, `Symbol`, +...), refinements and codecs are not JSON Schema and are not supported. ### Coercion @@ -164,6 +164,9 @@ Three passes run in order: - `integer`: as `number`, then truncated (`"3.5"` and `3.5` become 3). - `boolean`: `"true"`/`"false"` (any case), `"1"`/`"0"`, 1/0, `null` to `false`. - `null`: `"null"`, `"undefined"`, `""`, `"0"`, 0 and `false`. + - `bigint`: integer strings (`"12"`, `"12n"`; `"12.9"` truncated), numbers truncated, `"true"`/`"false"` and + booleans to 1n/0n, `null` to 0n. + - `undefined`: `"undefined"`, `"null"`, `""`, `"0"`, 0, 0n, `false` and `null`. - `array`: a single value becomes a one-item array, then each item converts; a tuple (`prefixItems` or an `items` array) converts its items and never wraps. - `object`: each property converts by `properties`, `patternProperties` or `additionalProperties`. @@ -184,13 +187,15 @@ coerced by any pass. ### Values -- A property whose value is `undefined` counts as absent, as in TypeBox. +- An optional property whose value is `undefined` counts as absent, as in TypeBox. A required property set to + `undefined` is present: valid when its schema takes `undefined` (`Type.Undefined()` in it, `Type.Unknown()`, + `Type.Any()`), else missing. - `{ type: "bytes", minLength, maxLength }` matches a `Uint8Array` of that length, an extension for values that never pass through JSON. -- `{ type: "bigint" }` (TypeBox's `Type.BigInt`) matches a `bigint`, with bigint `minimum`, `maximum`, - `exclusiveMinimum`, `exclusiveMaximum` and `multipleOf`. -- `{ type: "undefined" }` (TypeBox's `Type.Undefined`) matches `undefined`. A required property with this schema may be - left out, since a property set to `undefined` counts as absent. +- `{ type: "bigint" }` (TypeBox's `Type.BigInt`) matches a `bigint`. The number keywords (`minimum`, `maximum`, + `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`) apply to numbers and bigints alike, with bounds of either kind, + as in TypeBox. +- `{ type: "undefined" }` (TypeBox's `Type.Undefined`) matches `undefined`. - `equal(a, b)` is JSON equality as `const`, `enum` and `uniqueItems` compare (TypeBox's `Value.Equal`). ## Supported keywords diff --git a/packages/schema/src/checks.ts b/packages/schema/src/checks.ts index e4b61604a..4b48ed6d6 100644 --- a/packages/schema/src/checks.ts +++ b/packages/schema/src/checks.ts @@ -308,8 +308,10 @@ export function numberCheck( */ 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; + if ((typeof v === "number" && !Number.isInteger(v)) || (typeof d === "number" && !Number.isInteger(d))) + return false; + const divisor = BigInt(d); + return divisor !== 0n && BigInt(v) % divisor === 0n; } const q = v / d; if (Number.isInteger(q)) return true; @@ -367,8 +369,10 @@ export function declaredObject( } next = i + 3; const x = o[k]; - if (x === undefined) continue; const f = entries[i + 2] as number; + // An optional property set to undefined counts as absent; a required one is present, and its schema decides + // whether undefined is a value of it (TypeBox's Type.Undefined, Unknown, Any). + if (x === undefined && !(f & REQUIRED)) continue; const c = f & CODE; if (c !== 0 ? !leafOk(c, x, entries[i + 1]) : !(entries[i + 1] as Check)(x)) return false; if (f & REQUIRED) required++; @@ -394,7 +398,8 @@ export function openObject( const k = entries[i] as string; const x = o[k]; const f = entries[i + 2] as number; - if (x === undefined || (f & INHERITED && !Object.hasOwn(o, k))) { + // Absent, or an optional property set to undefined (which counts as absent); see declaredObject. + if ((x === undefined && !(f & REQUIRED && Object.hasOwn(o, k))) || (f & INHERITED && !Object.hasOwn(o, k))) { if (f & REQUIRED) return false; continue; } diff --git a/packages/schema/src/coerce.ts b/packages/schema/src/coerce.ts index f6f554723..698c1220d 100644 --- a/packages/schema/src/coerce.ts +++ b/packages/schema/src/coerce.ts @@ -206,6 +206,10 @@ export class Coercer { return orValue(tryBoolean(value), value); case "null": return orValue(tryNull(value), value); + case "bigint": + return orValue(tryBigInt(value), value); + case "undefined": + return orValue(tryUndefined(value), value); case "array": return this.convertArray(node, b, value); case "object": @@ -424,7 +428,9 @@ function convertLiteral(literal: unknown, value: unknown): unknown { ? tryNumber(value) : typeof literal === "boolean" ? tryBoolean(value) - : undefined; + : typeof literal === "bigint" + ? tryBigInt(value) + : undefined; return result !== undefined && result.value === literal ? result.value : value; } @@ -464,6 +470,30 @@ function bigIntOfString(value: string): bigint | undefined { return undefined; } +/** TypeBox's TryBigInt: booleans to 1n/0n, numbers truncated, null and undefined to 0n, bigint-like strings. */ +function tryBigInt(value: unknown): Converted { + if (typeof value === "bigint") return { value }; + if (typeof value === "boolean") return { value: value ? 1n : 0n }; + if (isFiniteNumber(value)) return { value: BigInt(Math.trunc(value)) }; + if (value === null || value === undefined) return { value: 0n }; + if (typeof value !== "string") return undefined; + const big = bigIntOfString(value); + if (big !== undefined) return { value: big }; + const lowercase = value.toLowerCase(); + return lowercase === "false" ? { value: 0n } : lowercase === "true" ? { value: 1n } : undefined; +} + +/** TypeBox's TryUndefined: 0n, false, 0, null, `"undefined"`, `"null"`, `""` and `"0"` to undefined. */ +function tryUndefined(value: unknown): Converted { + if (value === undefined || value === null || value === 0n || value === false || value === 0) + return { value: undefined }; + if (typeof value !== "string") return undefined; + const lowercase = value.toLowerCase(); + return lowercase === "undefined" || lowercase === "null" || value === "" || value === "0" + ? { value: undefined } + : undefined; +} + function tryBoolean(value: unknown): Converted { if (typeof value === "bigint") return value === 0n ? { value: false } : value === 1n ? { value: true } : undefined; if (typeof value === "boolean") return { value }; @@ -514,6 +544,10 @@ function matchesType(value: unknown, type: string): boolean { return typeof value === "string"; case "null": return value === null; + case "bigint": + return typeof value === "bigint"; + case "undefined": + return value === undefined; case "array": return Array.isArray(value); case "object": diff --git a/packages/schema/src/compiler.ts b/packages/schema/src/compiler.ts index 5894091f9..eb4492977 100644 --- a/packages/schema/src/compiler.ts +++ b/packages/schema/src/compiler.ts @@ -327,7 +327,7 @@ export class Compiler { private objectCore(node: SchemaObject, base: string, guard: Guard): Check | undefined { const properties = isSchemaObject(node.properties) ? node.properties : {}; - const required = requiredKeys(node, properties); + const required = Array.isArray(node.required) ? node.required.filter((k) => typeof k === "string") : []; const patterns = isSchemaObject(node.patternProperties) ? node.patternProperties : {}; const additional = node.additionalProperties; const keys = Object.keys(properties); @@ -602,18 +602,6 @@ function tagOf(branch: SchemaObject, key: string): unknown { return typeof tag.const === "object" && tag.const !== null ? undefined : tag.const; } -/** - * The keys `node` requires. An `undefined` property counts as absent, so a required property whose schema accepts only - * `undefined` (TypeBox's `Type.Undefined()`) is satisfied by `undefined` or by leaving it out. - */ -export function requiredKeys(node: SchemaObject, properties: SchemaObject): string[] { - const required = Array.isArray(node.required) ? node.required.filter((k) => typeof k === "string") : []; - return required.filter((k) => { - const property = Object.hasOwn(properties, k) ? properties[k] : undefined; - return !(isSchemaObject(property) && property.type === "undefined"); - }); -} - /** 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; diff --git a/packages/schema/src/explain.ts b/packages/schema/src/explain.ts index 539960e82..805e82d4a 100644 --- a/packages/schema/src/explain.ts +++ b/packages/schema/src/explain.ts @@ -14,7 +14,7 @@ import { TYPE_CHECKS, uniqueCheck, } from "./checks.ts"; -import { type Compiler, requiredKeys } from "./compiler.ts"; +import type { Compiler } from "./compiler.ts"; import { isSchemaObject } from "./resolver.ts"; import { sharedRegex } from "./shared.ts"; import type { SchemaObject, ValidationError } from "./types.ts"; @@ -179,8 +179,12 @@ export class Explainer { push: Push, ): void { const properties = isSchemaObject(node.properties) ? node.properties : {}; - for (const k of requiredKeys(node, properties)) { - if (!Object.hasOwn(v, k) || v[k] === undefined) push("required", "missing", pointer(path, k)); + const required = Array.isArray(node.required) ? node.required : []; + for (const k of required) { + if (typeof k !== "string") continue; + // Present and set to undefined: missing unless its schema takes undefined (see declaredObject). + const accepted = v[k] === undefined && Object.hasOwn(properties, k) && this.ok(properties[k], b, undefined); + if (!Object.hasOwn(v, k) || (v[k] === undefined && !accepted)) push("required", "missing", pointer(path, k)); } for (const k of Object.keys(properties)) { if (Object.hasOwn(v, k) && v[k] !== undefined) this.walk(properties[k], b, v[k], pointer(path, k), out); diff --git a/packages/schema/test/coerce-typebox.test.ts b/packages/schema/test/coerce-typebox.test.ts index f9a83140d..fd2801864 100644 --- a/packages/schema/test/coerce-typebox.test.ts +++ b/packages/schema/test/coerce-typebox.test.ts @@ -123,8 +123,7 @@ describe("Value.Convert.Array", () => { const R = Value.Convert(T, undefined); Assert.IsEqual(R, [undefined]); }); - // Skipped: the schema or the value has no JSON form. - it.skip("Should Convert 10", () => { + it("Should Convert 10", () => { const T = Type.Array(Type.BigInt()); const R = Value.Convert(T, 1n); Assert.IsEqual(R, [1n]); @@ -655,44 +654,37 @@ describe("Value.Convert.Literal", () => { // ------------------------------------------------------------------ // LiteralBigInt // ------------------------------------------------------------------ - // Skipped: the schema or the value has no JSON form. - it.skip("LiteralBigInt: Should convert 1", () => { + it("LiteralBigInt: Should convert 1", () => { const T = Type.Literal(100n); const R = Value.Convert(T, 100n); Assert.IsEqual(R, 100n); }); - // Skipped: the schema or the value has no JSON form. - it.skip("LiteralBigInt: Should convert 2", () => { + it("LiteralBigInt: Should convert 2", () => { const T = Type.Literal(100n); const R = Value.Convert(T, 100); Assert.IsEqual(R, 100n); }); - // Skipped: the schema or the value has no JSON form. - it.skip("LiteralBigInt: Should convert 3", () => { + it("LiteralBigInt: Should convert 3", () => { const T = Type.Literal(1n); const R = Value.Convert(T, "true"); Assert.IsEqual(R, 1n); }); - // Skipped: the schema or the value has no JSON form. - it.skip("LiteralBigInt: Should convert 4", () => { + it("LiteralBigInt: Should convert 4", () => { const T = Type.Literal(0n); const R = Value.Convert(T, "false"); Assert.IsEqual(R, 0n); }); - // Skipped: the schema or the value has no JSON form. - it.skip("LiteralBigInt: Should convert 5", () => { + it("LiteralBigInt: Should convert 5", () => { const T = Type.Literal(1n); const R = Value.Convert(T, "1"); Assert.IsEqual(R, 1n); }); - // Skipped: the schema or the value has no JSON form. - it.skip("LiteralBigInt: Should convert 6", () => { + it("LiteralBigInt: Should convert 6", () => { const T = Type.Literal(0n); const R = Value.Convert(T, "0"); Assert.IsEqual(R, 0n); }); - // Skipped: the schema or the value has no JSON form. - it.skip("LiteralBigInt: Should convert 7", () => { + it("LiteralBigInt: Should convert 7", () => { const T = Type.Literal(100n); const R = Value.Convert(T, "2"); Assert.IsEqual(R, "2"); @@ -700,44 +692,37 @@ describe("Value.Convert.Literal", () => { // ------------------------------------------------------------------ // LiteralBigInt // ------------------------------------------------------------------ - // Skipped: the schema or the value has no JSON form. - it.skip("LiteralBigInt: Should convert 1", () => { + it("LiteralBigInt: Should convert 1", () => { const T = Type.Literal(100n); const R = Value.Convert(T, 100n); Assert.IsEqual(R, 100n); }); - // Skipped: the schema or the value has no JSON form. - it.skip("LiteralBigInt: Should convert 2", () => { + it("LiteralBigInt: Should convert 2", () => { const T = Type.Literal(100n); const R = Value.Convert(T, 100); Assert.IsEqual(R, 100n); }); - // Skipped: the schema or the value has no JSON form. - it.skip("LiteralBigInt: Should convert 3", () => { + it("LiteralBigInt: Should convert 3", () => { const T = Type.Literal(1n); const R = Value.Convert(T, "true"); Assert.IsEqual(R, 1n); }); - // Skipped: the schema or the value has no JSON form. - it.skip("LiteralBigInt: Should convert 4", () => { + it("LiteralBigInt: Should convert 4", () => { const T = Type.Literal(0n); const R = Value.Convert(T, "false"); Assert.IsEqual(R, 0n); }); - // Skipped: the schema or the value has no JSON form. - it.skip("LiteralBigInt: Should convert 5", () => { + it("LiteralBigInt: Should convert 5", () => { const T = Type.Literal(1n); const R = Value.Convert(T, "1"); Assert.IsEqual(R, 1n); }); - // Skipped: the schema or the value has no JSON form. - it.skip("LiteralBigInt: Should convert 6", () => { + it("LiteralBigInt: Should convert 6", () => { const T = Type.Literal(0n); const R = Value.Convert(T, "0"); Assert.IsEqual(R, 0n); }); - // Skipped: the schema or the value has no JSON form. - it.skip("LiteralBigInt: Should convert 7", () => { + it("LiteralBigInt: Should convert 7", () => { const T = Type.Literal(100n); const R = Value.Convert(T, "2"); Assert.IsEqual(R, "2"); diff --git a/packages/schema/test/coerce.test.ts b/packages/schema/test/coerce.test.ts index 69c87876b..b264a442d 100644 --- a/packages/schema/test/coerce.test.ts +++ b/packages/schema/test/coerce.test.ts @@ -138,6 +138,25 @@ describe("validate", () => { }); }); +describe("bigint and undefined, as TypeBox's Value.Convert", () => { + it("converts to a bigint like TypeBox's TryBigInt", () => { + const schema = Type.Object({ n: Type.BigInt({ minimum: 0n }) }); + expect(compile(schema, { coerce: true }).parse({ n: "42" })).toEqual({ ok: true, value: { n: 42n } }); + expect(coerceValue(Type.BigInt(), "12n")).toBe(12n); + expect(coerceValue(Type.BigInt(), "12.9")).toBe(12n); + expect(coerceValue(Type.BigInt(), 3.7)).toBe(3n); + expect(coerceValue(Type.BigInt(), true)).toBe(1n); + expect(coerceValue(Type.BigInt(), "x")).toBe("x"); + }); + + it("converts to undefined like TypeBox's TryUndefined", () => { + for (const value of [null, 0, 0n, false, "", "0", "null", "undefined"]) { + expect(coerceValue(Type.Undefined(), value)).toBeUndefined(); + } + expect(coerceValue(Type.Undefined(), "x")).toBe("x"); + }); +}); + describe("pass 1: optional nulls", () => { it("drops null in optional non-nullable properties, also nested", () => { const schema = obj( diff --git a/packages/schema/test/validator.test.ts b/packages/schema/test/validator.test.ts index 14f242a53..bab12097c 100644 --- a/packages/schema/test/validator.test.ts +++ b/packages/schema/test/validator.test.ts @@ -460,18 +460,48 @@ describe("TypeBox's JavaScript types", () => { ]); }); - it("check Type.Undefined: only undefined, and a required undefined property may be left out", () => { + it("check Type.Undefined: only undefined, and a required property with it must be present", () => { expect(compile(Type.Undefined()).check(undefined)).toBe(true); expect(compile(Type.Undefined()).check(null)).toBe(false); - // A property set to undefined counts as absent, so a required one is satisfied by leaving it out too. const schema = Type.Object({ selection: Type.Literal("average"), principalId: Type.Undefined() }); const validator = compile(schema); expect(validator.check({ selection: "average", principalId: undefined })).toBe(true); - expect(validator.check({ selection: "average" })).toBe(true); + expect(validator.check({ selection: "average" })).toBe(false); expect(validator.check({ selection: "average", principalId: "x" })).toBe(false); expect(validator.check({ selection: "average", principalId: null })).toBe(false); + expect(validator.errors({ selection: "average" })).toEqual([ + { path: "/principalId", keyword: "required", message: "missing" }, + ]); expect(validator.errors({ selection: "average", principalId: "x" })).toEqual([ { path: "/principalId", keyword: "type", message: "expected undefined, got string" }, ]); }); + + it("check a required property set to undefined by its schema, as TypeBox does", () => { + // Present and undefined: valid when the schema takes undefined (Undefined in a union, Unknown, Any). + for (const property of [Type.Union([Type.String(), Type.Undefined()]), Type.Unknown(), Type.Any()]) { + for (const additionalProperties of [undefined, false]) { + const validator = compile( + Type.Object({ a: property }, additionalProperties === false ? { additionalProperties } : {}), + ); + expect(validator.check({ a: undefined })).toBe(true); + expect(validator.check({})).toBe(false); + } + } + // A required property whose schema does not take undefined stays missing when set to undefined. + const name = compile(Type.Object({ name: Type.String() })); + expect(name.check({ name: undefined })).toBe(false); + expect(name.errors({ name: undefined })).toEqual([{ path: "/name", keyword: "required", message: "missing" }]); + // An optional property set to undefined still counts as absent. + expect(compile(Type.Object({ name: Type.Optional(Type.String()) })).check({ name: undefined })).toBe(true); + }); + + it("check multipleOf of bigints exactly, however large", () => { + const five = compile(Type.BigInt({ multipleOf: 5n })); + expect(five.check(10n ** 400n)).toBe(true); + expect(five.check(10n ** 400n + 1n)).toBe(false); + // A bigint is no multiple of 0 or of a fraction. + expect(compile({ multipleOf: 0n }).check(4n)).toBe(false); + expect(compile({ multipleOf: 0.5 }).check(3n)).toBe(false); + }); });