From af79b0c3d75c099d37365dcbc033414b5c384835 Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:36:38 +0100 Subject: [PATCH] feat(schema): TypeBox parity for extra types (#11833) Adds unsafe(), recursive(), Validator.validate() (check plus errors, no coercion), bigint(), equal(), validation and coercion of TypeBox's Type.BigInt/Type.Undefined, and Static for TypeBox's Enum, Tuple, BigInt and Undefined. Also fixes a required property set to undefined being rejected when its schema accepts undefined, as TypeBox allows. --- packages/ai/CHANGELOG.md | 1 + packages/ai/test/validation-probes.test.ts | 22 ++----- packages/schema/CHANGELOG.md | 10 +++ packages/schema/README.md | 37 ++++++++--- packages/schema/src/builders.ts | 40 ++++++++++++ packages/schema/src/checks.ts | 70 ++++++++++++++++----- packages/schema/src/coerce.ts | 36 ++++++++++- packages/schema/src/compiler.ts | 23 ++++--- packages/schema/src/explain.ts | 36 ++++++----- packages/schema/src/index.ts | 5 ++ packages/schema/src/typebox-static.ts | 16 ++++- packages/schema/src/validator.ts | 15 ++++- packages/schema/test/builders.test.ts | 59 +++++++++++++++++ packages/schema/test/coerce-typebox.test.ts | 45 +++++-------- packages/schema/test/coerce.test.ts | 42 +++++++++++++ packages/schema/test/equal.test.ts | 29 +++++++++ packages/schema/test/typebox-static.test.ts | 30 +++++++-- packages/schema/test/validator.test.ts | 63 +++++++++++++++++++ 18 files changed, 477 insertions(+), 102 deletions(-) create mode 100644 packages/schema/test/equal.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 369cb68f5..dc2654166 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -10,6 +10,7 @@ ### Fixed +- Fixed tool arguments for `Type.BigInt()` and `Type.Undefined()` parameters being rejected: they convert as TypeBox converted them (`"42"` and `42` to `42n`, `null` to `undefined`), now that pi-schema supports both types. `Type.Void()` still rejects every JSON argument. - Fixed OpenCode Go Claude Haiku 5.5 failing with a 400 (`Invalid signature in thinking block ... bound to a different conversation`) after the tool list, system prompt, or earlier history changed, which left the session failing on every retry. OpenCode Go and Zen Claude 5.5 models, Opus 5, and Fable 5.1 now use per-message effort and send `thinking.block_binding: drop_block`, like the Anthropic provider ([#10954](https://github.com/earendil-works/pi/issues/10954)) - Fixed Anthropic non-strict tool schemas dropping root `$defs` and `definitions`, which left property `$ref`s pointing at nothing. Claude then produced wrong arguments for such tools (common in MCP servers), for example a JSON string instead of an object, and some Anthropic-compatible gateways rejected the request outright ([#9557](https://github.com/earendil-works/pi/issues/9557)) - Fixed OpenRouter rejecting requests with a 400 (`This endpoint's maximum context length is ...`) for models whose output limit is close to the context window, such as DeepSeek flash. OpenRouter counts the input with its own tokenizer, which can be several percent higher than the provider's count, and pi left only 4,096 tokens spare. pi then treated the 400 as a context overflow and compacted the session, even at around 20% of the window. The default output limit now leaves 15% of the estimated input unused, and near a full window at most 32,768 output tokens are requested ([#10497](https://github.com/earendil-works/pi/issues/10497)) diff --git a/packages/ai/test/validation-probes.test.ts b/packages/ai/test/validation-probes.test.ts index 0ddfe8d12..1a3edd0ba 100644 --- a/packages/ai/test/validation-probes.test.ts +++ b/packages/ai/test/validation-probes.test.ts @@ -1,7 +1,7 @@ // The reviewers' probes of the schema switch (round 1) as regression tests: TypeBox-built and plain JSON // (MCP-shaped) tool schemas with arguments through validateToolArguments. Each case's outcome is "ok ", "REJECT " or "THROW ". Cases marked as TypeBox-only record the documented -// differences from TypeBox (see the CHANGELOG): Refine and BigInt. +// differences from TypeBox (see the CHANGELOG): Refine. BigInt and Undefined convert as TypeBox converted them. // biome-ignore-all lint/suspicious/noTemplateCurlyInString: TypeBox template literal syntax. // biome-ignore-all lint/suspicious/noThenProperty: JSON Schema's `then` keyword. @@ -465,8 +465,8 @@ const expected: Record = { 'codec {"c":12}': 'ok {"c":"12"}', "undefined-optional {}": "ok {}", 'undefined-optional {"u":null}': "ok {}", - 'bigint {"n":1}': "REJECT - n: expected bigint, got integer", - 'bigint {"n":"12"}': "REJECT - n: expected bigint, got string", + 'bigint {"n":1}': 'ok {"n":"1n"}', + 'bigint {"n":"12"}': 'ok {"n":"12n"}', 'refine {"n":5}': 'ok {"n":5}', 'refine {"n":-5}': 'ok {"n":-5}', 'template-literal {"t":"id-12"}': 'ok {"t":"id-12"}', @@ -631,19 +631,9 @@ describe("reviewer probes (Sol)", () => { $defs: { Tree: { anyOf: [{ type: "string" }, { type: "array", items: { $ref: "#/$defs/Tree" } }] } }, }; const solCases: Array<[string, object, unknown, string]> = [ - // TypeBox-only: Type.BigInt and Type.Undefined have no JSON form; TypeBox converted to them. - [ - "BigInt from a JSON string", - Type.Object({ n: Type.BigInt() }), - { n: "42" }, - "REJECT - n: expected bigint, got string", - ], - [ - "Undefined from JSON null", - Type.Object({ n: Type.Undefined() }), - { n: null }, - "REJECT - n: expected undefined, got null", - ], + // Type.BigInt and Type.Undefined have no JSON form: arguments convert to them, as TypeBox converted them. + ["BigInt from a JSON string", Type.Object({ n: Type.BigInt() }), { n: "42" }, 'ok {"n":"42n"}'], + ["Undefined from JSON null", Type.Object({ n: Type.Undefined() }), { n: null }, "ok {}"], // TypeBox-only: its additionalProperties also converted declared properties; pi-schema converts only undeclared ones. [ "additionalProperties on declared properties", diff --git a/packages/schema/CHANGELOG.md b/packages/schema/CHANGELOG.md index 6c352d6bf..01342cf12 100644 --- a/packages/schema/CHANGELOG.md +++ b/packages/schema/CHANGELOG.md @@ -2,12 +2,22 @@ ## [Unreleased] +### Added + +- `unsafe(schema)` types a schema as accepting `T` without checking it, as TypeBox's `Type.Unsafe` does. +- `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 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 - Faster checks, still without eval: object and array loops run `const`, small `enum`s, type lists, number bounds, string lengths and patterns inline instead of calling a closure; a closed object looks up keys in declared order first; `anyOf` branches that are bare types (as `nullable` builds) run as one type test. Checks take about 16% less time across the benchmark's speed cases, up to 30% less for tagged unions and 45% less for nullable properties. ### 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 f1ff34f4d..62b03f899 100644 --- a/packages/schema/README.md +++ b/packages/schema/README.md @@ -47,7 +47,8 @@ or not. `check(value)` is the fast path: a boolean, no allocations for most schemas, and a plain function you can pass around. `errors(value)` explains a failure and returns `[]` for a valid value. It is built on the first failure and -kept, so a schema whose values always pass never pays for it. +kept, so a schema whose values always pass never pays for it. `validate(value)` combines the two without changing +the value: `{ ok: true, value }` or `{ ok: false, errors }`. ### Builders @@ -65,6 +66,9 @@ kept, so a schema whose values always pass never pays for it. | `union([...])` | `{ anyOf: [...] }` | the union | | `nullable(schema)` | `{ anyOf: [schema, { type: "null" }] }` | `T \| null` | | `bytes({ minLength, maxLength })` | `{ type: "bytes", ... }` | `Uint8Array` | +| `bigint({ minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf })` | `{ type: "bigint", ... }`, bigint bounds | `bigint` | +| `unsafe(schema)` | `schema` as given (`{}` by default) | `T`, unchecked | +| `recursive(id, (self) => schema)` | `schema` with `$id: id`; `self` is `{ $ref: id }` | `T` | Every builder also takes `title`, `description`, `examples`, `deprecated` and `default`. A `default` is an annotation; validation never inserts it. @@ -77,11 +81,20 @@ compile(Block).errors({ kind: "video" }); // [{ path: "/kind", keyword: "anyOf", message: 'expected one of ["text","image"]' }] ``` +A recursive schema needs its type spelled out, since TypeScript cannot infer one from the builders. Its `id` must be +unique among the recursive schemas within one schema: + +```typescript +type Tree = { name: string; children: Tree[] }; +const Tree = recursive("Tree", (self) => obj({ name: str(), children: arr(self) })); +``` + `Static` also types schemas built with TypeBox 1.x, read from TypeBox's type shapes (pi-schema does not import TypeBox): `String`, `Number`, `Integer`, `Boolean`, `Null`, `Literal`, `Array`, `Object` (with `Type.Optional` and -`Type.Readonly` properties), `Union`, `Record`, `Unsafe`, `Unknown` and `Any` give TypeBox's own static type, so -`Static` is `{ path: string }`. Other TypeBox kinds (`Enum`, `Tuple`, -`Intersect`, `Cyclic`, ...) give `unknown` where they stand. Other JSON Schema objects give `unknown`. +`Type.Readonly` properties), `Union`, `Record`, `Enum`, `Tuple`, `BigInt`, `Undefined`, `Unsafe`, `Unknown` and `Any` +give TypeBox's own static type, so `Static` is `{ path: string }`. Other +TypeBox kinds (`Intersect`, `Cyclic`, `Ref`, ...) give `unknown` where they stand. Other JSON Schema objects give +`unknown`. ### Options @@ -116,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 @@ -151,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`. @@ -171,9 +187,16 @@ 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`. 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/builders.ts b/packages/schema/src/builders.ts index 2dc61a520..9705ead9c 100644 --- a/packages/schema/src/builders.ts +++ b/packages/schema/src/builders.ts @@ -66,6 +66,23 @@ export function int(options: Annotations & NumberBounds = {}): Schema & BigintBounds = {}): Schema { + return { type: "bigint", ...options }; +} + /** A boolean. */ export function bool(options: Annotations = {}): Schema { return { type: "boolean", ...options }; @@ -138,6 +155,29 @@ export function map(value: Schema, options: Annotations> return { type: "object", additionalProperties: value, ...options }; } +/** + * `schema` typed as accepting `T`, for values the builders cannot express (a type checked elsewhere, a schema from + * another system). The schema is returned as it is: nothing checks that its values are `T`. + */ +export function unsafe(schema: Schema = {}): Schema { + // The brand is type-level only; asserting it is what this builder is for. + return schema as Schema; +} + +/** + * A schema that contains itself, as a tree's children are trees: `build` receives a reference to the schema it + * returns. TypeScript cannot infer a recursive type from the builders, so `T` is given: + * `recursive("Tree", (tree) => obj({ name: str(), children: arr(tree) }))`. `id` becomes the schema's `$id` + * and the target of the reference, so it must be unique among the recursive schemas within one schema. + * + * The counterpart of TypeBox's `Type.Cyclic` + `Type.Ref`, with other JSON: an `$id` on the schema instead of `$defs` + * and a root `$ref`. It validates the same values. The JSON differs only where a schema is published, and its type is + * written once instead of inferred, which a recursive schema (a file tree) needs only once. + */ +export function recursive(id: string, build: (self: Schema) => Schema): Schema { + return { ...build({ $ref: id }), $id: id }; +} + /** A Uint8Array with optional length bounds (an extension of JSON Schema for values that never pass through JSON). */ export function bytes(options: Annotations & LengthBounds = {}): Schema { return { type: "bytes", ...options }; diff --git a/packages/schema/src/checks.ts b/packages/schema/src/checks.ts index 4b06eaf68..d119e1fb0 100644 --- a/packages/schema/src/checks.ts +++ b/packages/schema/src/checks.ts @@ -13,6 +13,8 @@ const IS_BOOLEAN: Check = (v) => typeof v === "boolean"; const IS_NULL: Check = (v) => v === null; export const IS_ARRAY: Check = (v) => Array.isArray(v); const IS_BYTES: Check = (v) => v instanceof Uint8Array; +const IS_BIGINT: Check = (v) => typeof v === "bigint"; +const IS_UNDEFINED: Check = (v) => v === undefined; export const IS_OBJECT: Check = (v) => isObject(v); /** Whether `v` is a JSON object: not null, an array or bytes. */ @@ -20,7 +22,10 @@ export function isObject(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v) && !(v instanceof Uint8Array); } -/** The check of a `type` name; `bytes` (a Uint8Array) extends JSON Schema for values that never pass through JSON. */ +/** + * 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. + */ export const TYPE_CHECKS: ReadonlyMap = new Map([ ["string", IS_STRING], ["number", IS_NUMBER], @@ -30,6 +35,8 @@ export const TYPE_CHECKS: ReadonlyMap = new Map([ ["array", IS_ARRAY], ["object", IS_OBJECT], ["bytes", IS_BYTES], + ["bigint", IS_BIGINT], + ["undefined", IS_UNDEFINED], ]); /** @@ -175,6 +182,8 @@ const TYPE_BITS: ReadonlyMap = new Map([ ["array", 32], ["object", 64], ["bytes", 128], + ["bigint", 256], + ["undefined", 512], ]); /** The bits of the named types; unknown names add none. */ @@ -195,6 +204,10 @@ function typeBits(v: unknown): number { return 8; case "object": return v === null ? 16 : Array.isArray(v) ? 32 : v instanceof Uint8Array ? 128 : 64; + case "bigint": + return 256; + case "undefined": + return 512; default: return 0; } @@ -262,26 +275,47 @@ 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. + * + * Deliberately unlike TypeBox, which throws there (`BigInt(0.5)`, division by zero): a check answers for every value + * and never throws, and no real schema pairs a bigint with a fractional or zero `multipleOf`. + */ +export function isMultiple(v: Bound, d: Bound): boolean { + if (typeof v === "bigint" || typeof d === "bigint") { + 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; if (!Number.isFinite(q)) return v % d === 0; @@ -338,8 +372,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++; @@ -365,7 +401,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; } @@ -535,7 +572,10 @@ export function taggedCheck(key: string, tags: readonly unknown[], checks: reado }; } -/** JSON equality: same primitives, arrays with equal items in order, objects with the same keys and equal values. */ +/** + * JSON equality, as `const`, `enum` and `uniqueItems` compare: the same primitives, arrays with equal items in order, + * objects with the same keys and equal values (key order does not matter). + */ export function deepEqual(a: unknown, b: unknown): boolean { if (a === b) return true; if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false; 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 f47e79bc7..eb4492977 100644 --- a/packages/schema/src/compiler.ts +++ b/packages/schema/src/compiler.ts @@ -24,6 +24,7 @@ import { ALWAYS, and, anyCheck, + type Bound, bytesCheck, constCheck, containsCheck, @@ -149,8 +150,9 @@ export class Compiler { } 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); @@ -283,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); @@ -600,6 +602,11 @@ function tagOf(branch: SchemaObject, key: string): unknown { return typeof tag.const === "object" && tag.const !== null ? undefined : tag.const; } +/** 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 5ce216c80..805e82d4a 100644 --- a/packages/schema/src/explain.ts +++ b/packages/schema/src/explain.ts @@ -3,6 +3,7 @@ import { newAnnotations } from "./annotations.ts"; import { + type Bound, codePoints, constCheck, containsCheck, @@ -82,7 +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 === "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); @@ -157,18 +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}`); - } + // 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 (isBound(exclusiveMaximum) && v >= exclusiveMaximum) + push("exclusiveMaximum", `expected < ${exclusiveMaximum}`); + if (isBound(multipleOf) && !isMultiple(v, multipleOf)) push("multipleOf", `expected a multiple of ${multipleOf}`); } private walkObject( @@ -182,8 +181,10 @@ export class Explainer { const properties = isSchemaObject(node.properties) ? node.properties : {}; const required = Array.isArray(node.required) ? node.required : []; for (const k of required) { - if (typeof k === "string" && (!Object.hasOwn(v, k) || v[k] === undefined)) - push("required", "missing", pointer(path, k)); + 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); @@ -379,6 +380,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")}`; } @@ -387,6 +392,7 @@ function typeName(v: unknown): string { if (v === null) return "null"; if (Array.isArray(v)) return "array"; if (v instanceof Uint8Array) return "bytes"; + if (typeof v === "bigint") return "bigint"; if (typeof v === "number" && Number.isInteger(v)) return "integer"; return typeof v; } diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index c57ede2e9..a8c0b3797 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -4,6 +4,8 @@ export { type Annotations, arr, + type BigintBounds, + bigint, bool, bytes, int, @@ -19,6 +21,7 @@ export { oneOf, opt, type Properties, + recursive, type Schema, type Static, type StaticObject, @@ -26,6 +29,8 @@ export { tuple, union, unknown, + unsafe, } from "./builders.ts"; +export { deepEqual as equal } from "./checks.ts"; export type { Check, CompileOptions, FormatCheck, JsonSchema, ParseResult, ValidationError } from "./types.ts"; export { CoercingValidator, compile, Validator } from "./validator.ts"; diff --git a/packages/schema/src/typebox-static.ts b/packages/schema/src/typebox-static.ts index ce9f29b51..4567559ab 100644 --- a/packages/schema/src/typebox-static.ts +++ b/packages/schema/src/typebox-static.ts @@ -1,6 +1,8 @@ // The static types of TypeBox-built schemas, read from the type shapes TypeBox 1.x declares (`'~kind'`, `'~optional'`, // `'~readonly'`, `'~unsafe'`) without importing TypeBox, so `Static` also types tool arguments of TypeBox schemas. -// Only the common kinds are mapped; every other kind (Enum, Tuple, Intersect, Cyclic, Ref, ...) is `unknown`. +// Only the common kinds are mapped; every other kind (Intersect, Cyclic, Ref, ...) is `unknown`. Tuples are plain +// tuples: an element marked `Type.Optional`, `Type.Rest` or `Type.Readonly` is typed by its schema alone (`[string]` +// where TypeBox gives `[string?]`), unlike TypeBox; tuples of tool and API schemas are fixed pairs, typed exactly. import type { Static } from "./builders.ts"; @@ -17,6 +19,8 @@ interface Primitives { Integer: number; Boolean: boolean; Null: null; + BigInt: bigint; + Undefined: undefined; Unknown: unknown; Any: any; } @@ -43,7 +47,15 @@ type KindStatic = K extends keyof Primitives ? S extends { readonly patternProperties: infer P } ? Record, Static> : unknown - : unknown; + : K extends "Enum" + ? S extends { readonly enum: readonly (infer V)[] } + ? V + : unknown + : K extends "Tuple" + ? S extends { readonly items: infer I extends readonly unknown[] } + ? { -readonly [N in keyof I]: Static } + : unknown + : unknown; type Optional = { readonly "~optional": true }; diff --git a/packages/schema/src/validator.ts b/packages/schema/src/validator.ts index 65781e5dd..41f25849e 100644 --- a/packages/schema/src/validator.ts +++ b/packages/schema/src/validator.ts @@ -52,6 +52,18 @@ export class Validator { this.explainer ??= new Explainer(compiler(this.schema, this.options), this.schema); return this.explainer.errors(value); } + + /** + * `value` itself when it is valid, else its errors: check() and errors() in one result, without coercion. + * + * Not TypeBox's `Value.Parse`, which also clones, fills defaults, converts and strips undeclared properties before + * it checks. This only checks: the strict replacement where `Value.Parse` is used to validate (its other steps + * change nothing for a valid value of a schema without defaults or extra properties). Use `parse()` with + * `coerce: true` for conversion. + */ + validate(value: unknown): ParseResult { + return this.check(value) ? { ok: true, value } : { ok: false, errors: this.errors(value) }; + } } /** A validator compiled with `coerce: true`: it also coerces loosely typed input towards the schema. */ @@ -71,8 +83,7 @@ export class CoercingValidator extends Validator { /** Coerces `value`, then checks it: the coerced value when it is valid, else its errors. */ parse(value: unknown): ParseResult { - const coerced = this.coerce(value); - return this.check(coerced) ? { ok: true, value: coerced } : { ok: false, errors: this.errors(coerced) }; + return this.validate(this.coerce(value)); } } diff --git a/packages/schema/test/builders.test.ts b/packages/schema/test/builders.test.ts index 2d13886c7..f87b27b41 100644 --- a/packages/schema/test/builders.test.ts +++ b/packages/schema/test/builders.test.ts @@ -3,6 +3,7 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import { arr, + bigint, bool, bytes, compile, @@ -15,11 +16,14 @@ import { obj, oneOf, opt, + recursive, + type Schema, type Static, str, tuple, union, unknown, + unsafe, } from "../src/index.ts"; describe("builders", () => { @@ -91,6 +95,61 @@ describe("builders", () => { expect(value.x + value.y).toBe(3); }); + it("build a bigint with bounds, typed bigint", () => { + const metric = nullable(bigint({ minimum: 0n })); + expect(metric).toEqual({ anyOf: [{ type: "bigint", minimum: 0n }, { type: "null" }] }); + expectTypeOf>().toEqualTypeOf(); + expect(compile(metric).check(3n)).toBe(true); + expect(compile(metric).check(-1n)).toBe(false); + expect(compile(metric).check(3)).toBe(false); + }); + + it("type a schema as given with unsafe", () => { + type Usage = { input: number; output: number }; + const usage = unsafe(); + const level = unsafe<"low" | "high">({ type: "string", enum: ["low", "high"] }); + expect(usage).toEqual({}); + expect(level).toEqual({ type: "string", enum: ["low", "high"] }); + const status = obj({ usage: nullable(usage), level }); + expectTypeOf>().toEqualTypeOf<{ usage: Usage | null; level: "low" | "high" }>(); + // Nothing checks the given type: an unconstrained schema accepts any value. + expect(compile(usage).check("not usage")).toBe(true); + expect(compile(level).check("medium")).toBe(false); + }); + + it("build a recursive schema that checks every level", () => { + type Tree = { name: string; children: Tree[] }; + const tree = recursive("Tree", (self) => obj({ name: str(), children: arr(self) })); + expect(tree).toEqual({ + type: "object", + properties: { name: { type: "string" }, children: { type: "array", items: { $ref: "Tree" } } }, + required: ["name", "children"], + additionalProperties: false, + $id: "Tree", + }); + expectTypeOf(tree).toEqualTypeOf>(); + const validator = compile(tree); + expect(validator.check({ name: "a", children: [{ name: "b", children: [] }] })).toBe(true); + const deep = { name: "a", children: [{ name: "b", children: [{ name: 1, children: [] }] }] }; + expect(validator.errors(deep)).toEqual([ + { path: "/children/0/children/0/name", keyword: "type", message: "expected string, got integer" }, + ]); + }); + + it("nest recursive schemas with distinct ids inside other schemas", () => { + type Tree = { name: string; children: Tree[] }; + type List = { value: number; next: List | null }; + const tree = recursive("Tree", (self) => obj({ name: str(), children: arr(self) })); + const list = recursive("List", (self) => obj({ value: num(), next: nullable(self) })); + const both = obj({ tree, list: opt(list) }); + expectTypeOf>().toEqualTypeOf<{ tree: Tree; list?: List }>(); + const { check } = compile(both); + const root = { name: "a", children: [{ name: "b", children: [] }] }; + expect(check({ tree: root, list: { value: 1, next: { value: 2, next: null } } })).toBe(true); + expect(check({ tree: root, list: { value: 1, next: { value: "2", next: null } } })).toBe(false); + expect(check({ tree: { name: "a", children: [{ name: "b", children: [{ name: 3 }] }] } })).toBe(false); + }); + it("compile into checks with the declared semantics", () => { const validator = compile(obj({ name: str({ minLength: 1 }), tags: opt(arr(str(), { uniqueItems: true })) })); expect(validator.check({ name: "a" })).toBe(true); 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 3031e6c92..b264a442d 100644 --- a/packages/schema/test/coerce.test.ts +++ b/packages/schema/test/coerce.test.ts @@ -115,6 +115,48 @@ describe("parse", () => { }); }); +describe("validate", () => { + it("checks without coercing, with or without the coerce option", () => { + const schema = obj({ count: num(), tags: arr(str()) }); + const value = { count: 42, tags: ["a"] }; + for (const validator of [compile(schema), compile(schema, { coerce: true })]) { + const result = validator.validate(value); + expect(result).toEqual({ ok: true, value }); + if (result.ok) { + expect(result.value).toBe(value); + expectTypeOf(result.value).toEqualTypeOf<{ count: number; tags: string[] }>(); + } + // parse() would turn "42" into 42 and "a" into ["a"]; validate() reports both. + expect(validator.validate({ count: "42", tags: "a" })).toEqual({ + ok: false, + errors: [ + { path: "/count", keyword: "type", message: "expected number, got string" }, + { path: "/tags", keyword: "type", message: "expected array, got string" }, + ], + }); + } + }); +}); + +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/equal.test.ts b/packages/schema/test/equal.test.ts new file mode 100644 index 000000000..79164af87 --- /dev/null +++ b/packages/schema/test/equal.test.ts @@ -0,0 +1,29 @@ +// equal(): JSON equality, a drop-in for TypeBox's Value.Equal. + +import { Value } from "typebox/value"; +import { describe, expect, it } from "vitest"; +import { equal } from "../src/index.ts"; + +describe("equal", () => { + it("compares JSON values structurally, as TypeBox's Value.Equal does", () => { + const cases: [unknown, unknown][] = [ + [1, 1], + ["a", "b"], + [null, null], + [null, undefined], + [ + { a: 1, b: [1, { c: 2 }] }, + { b: [1, { c: 2 }], a: 1 }, + ], + [{ a: 1 }, { a: 1, b: 2 }], + [ + [1, 2], + [2, 1], + ], + [[1], { 0: 1 }], + [{}, []], + [{ a: undefined }, {}], + ]; + for (const [a, b] of cases) expect(equal(a, b)).toBe(Value.Equal(a, b)); + }); +}); diff --git a/packages/schema/test/typebox-static.test.ts b/packages/schema/test/typebox-static.test.ts index c62ec03f9..c63c92689 100644 --- a/packages/schema/test/typebox-static.test.ts +++ b/packages/schema/test/typebox-static.test.ts @@ -52,16 +52,34 @@ describe("Static of TypeBox schemas", () => { expectTypeOf>().toEqualTypeOf>(); }); + it("maps enums and tuples as TypeBox does", () => { + const enumeration = Type.Enum(["a", "b", 3]); + expectTypeOf>().toEqualTypeOf<"a" | "b" | 3>(); + expectTypeOf>().toEqualTypeOf>(); + const tuple = Type.Tuple([Type.Number(), Type.Union([Type.Number(), Type.Null()])]); + expectTypeOf>().toEqualTypeOf<[number, number | null]>(); + expectTypeOf>().toEqualTypeOf>(); + const nested = Type.Object({ points: Type.Array(Type.Tuple([Type.String(), Type.Enum(["x", "y"])])) }); + expectTypeOf>().toEqualTypeOf<{ points: [string, "x" | "y"][] }>(); + }); + + it("maps BigInt and Undefined as TypeBox does", () => { + const big = Type.BigInt({ minimum: 0n }); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf>(); + const metric = Type.Union([Type.BigInt(), Type.Null()]); + expectTypeOf>().toEqualTypeOf(); + const none = Type.Undefined(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf>(); + }); + it("gives unknown for kinds it does not map", () => { - const enumeration = Type.Enum(["a", "b"]); - const tuple = Type.Tuple([Type.String()]); const intersect = Type.Intersect([Type.Object({ a: Type.String() }), Type.Object({ b: Type.String() })]); - expectTypeOf>().toEqualTypeOf(); - expectTypeOf>().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); // Inside a mapped kind, an unmapped kind is unknown only where it stands. - const object = Type.Object({ pair: Type.Tuple([Type.String()]), name: Type.String() }); - expectTypeOf>().toEqualTypeOf<{ pair: unknown; name: string }>(); + const object = Type.Object({ both: intersect, name: Type.String() }); + expectTypeOf>().toEqualTypeOf<{ both: unknown; name: string }>(); }); it("types compile() and parse() for TypeBox schemas", () => { diff --git a/packages/schema/test/validator.test.ts b/packages/schema/test/validator.test.ts index 565938613..bab12097c 100644 --- a/packages/schema/test/validator.test.ts +++ b/packages/schema/test/validator.test.ts @@ -442,3 +442,66 @@ describe("TypeBox's extra formats", () => { expect(fragment.check("/a")).toBe(false); }); }); + +describe("TypeBox's JavaScript types", () => { + it("check Type.BigInt with its bounds, as TypeBox does", () => { + const schema = Type.Union([Type.BigInt({ minimum: 0n }), Type.Null()]); + const validator = compile(schema); + for (const value of [5n, 0n, null]) expect(validator.check(value)).toBe(true); + for (const value of [-1n, 5, "5", undefined]) expect(validator.check(value)).toBe(false); + expect(compile(Type.BigInt({ maximum: 10n, multipleOf: 5n })).check(10n)).toBe(true); + expect(compile(Type.BigInt({ multipleOf: 5n })).check(7n)).toBe(false); + expect(compile(Type.BigInt({ exclusiveMaximum: 3n })).check(3n)).toBe(false); + expect(compile(Type.BigInt({ minimum: 0n })).errors(-2n)).toEqual([ + { path: "", keyword: "minimum", message: "expected >= 0" }, + ]); + expect(compile(Type.BigInt()).errors(2)).toEqual([ + { path: "", keyword: "type", message: "expected bigint, got integer" }, + ]); + }); + + 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); + 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(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); + }); +});