feat(omptype): implemented advanced schema operators and type features
- Added support for bigint, RegExp literals, and regex execution in the string DSL alongside intersection and pipe operators. - Enhanced error aggregation, cycle detection, and alternative branch rendering for union and collection validations. - Updated object compilation and evaluation to support symbol and pattern indexes. - Extended type methods with subtyping, type intersection, and scope building features.
This commit is contained in:
@@ -7,6 +7,21 @@
|
||||
- Added `io: 'input'` and `io: 'output'` options to `toJsonSchema()`, supporting input validation shapes and piped `.to()` target types
|
||||
- Added Standard Schema V1 interop: every schema exposes `~standard` with synchronous validation, enabling direct use with `@t3-oss/env`, tRPC, and other Standard Schema consumers.
|
||||
- Added `fromJsonSchema()`, rebuilding callable schemas from JSON Schema documents (draft-07 / draft-2020-12 structural keywords, string formats, `$defs` recursion, enums, and `anyOf`/`oneOf`/`allOf` composition) — the inverse of `Type.toJsonSchema()`.
|
||||
- Added `$defs`/`$ref` emission for recursive alias schemas in `toJsonSchema()` (draft-07 converts to `definitions`), preventing unbounded recursion on cyclic scopes.
|
||||
- Added `AnyType`, a minimal structural constraint for generic functions accepting any schema without descending the recursive fluent surface.
|
||||
- Root `.default()` values now materialize for `undefined` input in direct calls and at the Standard Schema boundary (factories run per call).
|
||||
- `.narrow()`/`.filter()` boolean overloads accept `OmpErrors` returns, so `cond || ctx.reject(...)` recipes typecheck.
|
||||
|
||||
### Changed
|
||||
|
||||
- Restored low-overhead schema construction by lazily activating advanced normalization and compatibility machinery.
|
||||
- `.default()` is typed input-side (`i | (() => i)`) and marks the schema's input as optional (`i | undefined`).
|
||||
- Parse keywords (`string.integer.parse`, `parse.number`, ...) now infer their morph output inside union strings, and input-side inference is union-aware.
|
||||
- Object-literal inference for `.merge()`/`.or()`/`.and()` unwraps embedded schema values (output and input sides).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Alias intersections defer through memoized lazy nodes, so cyclic scope schemas no longer overflow the stack in `.and()` or morph-union determinism checks.
|
||||
|
||||
## [17.2.7] - 2026-08-03
|
||||
|
||||
|
||||
+628
-273
File diff suppressed because it is too large
Load Diff
+247
-49
@@ -12,18 +12,23 @@
|
||||
export interface ErrorContext {
|
||||
readonly code: string;
|
||||
readonly path: readonly PropertyKey[];
|
||||
readonly propString: string;
|
||||
readonly data: unknown;
|
||||
readonly expected: string;
|
||||
readonly actual: string;
|
||||
readonly problem: string;
|
||||
readonly description: string;
|
||||
readonly rule?: unknown;
|
||||
}
|
||||
|
||||
/** Per-schema overrides for validation error text. */
|
||||
export interface ErrorConfig {
|
||||
readonly expected?: string | ((context: ErrorContext) => string);
|
||||
readonly actual?: string | ((context: ErrorContext) => string);
|
||||
readonly actual?: string | ((data: unknown) => string);
|
||||
readonly problem?: string | ((context: ErrorContext) => string);
|
||||
readonly message?: string | ((context: ErrorContext) => string);
|
||||
/** Internal: custom predicate expectations display the offending value rather than its domain. */
|
||||
readonly preserveActual?: boolean;
|
||||
}
|
||||
|
||||
function format(
|
||||
@@ -50,6 +55,17 @@ export class OmpError {
|
||||
this.#rawExpected = expected;
|
||||
this.#config = config;
|
||||
}
|
||||
/** Prefix this failure when a nested schema delegates validation. */
|
||||
prefix(key: PropertyKey): this {
|
||||
this.path.unshift(key);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Apply schema-local formatting to this failure. */
|
||||
configure(config: ErrorConfig): this {
|
||||
this.#config = { ...this.#config, ...config };
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Stable category for programmatic error handling. */
|
||||
get code(): string {
|
||||
@@ -57,19 +73,34 @@ export class OmpError {
|
||||
}
|
||||
|
||||
#context(expected: string, actual: string, problem = ""): ErrorContext {
|
||||
return { code: this.code, path: this.path, data: this.data, expected, actual, problem };
|
||||
const { description, rule } = describeExpectation(this.#rawExpected);
|
||||
return {
|
||||
code: this.code,
|
||||
path: this.path,
|
||||
propString: formatPath(this.path),
|
||||
data: this.data,
|
||||
expected,
|
||||
actual,
|
||||
problem,
|
||||
description,
|
||||
...(rule === undefined ? {} : { rule }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Human-readable expectation, including a configured override. */
|
||||
get expected(): string {
|
||||
const actual = describeValue(this.data);
|
||||
return format(this.#config?.expected, this.#context(this.#rawExpected, actual), this.#rawExpected);
|
||||
const actual = describeValue(this.data, this.#config?.preserveActual ? "predicate" : this.code);
|
||||
const parts = this.#rawExpected.split(" or ");
|
||||
const fallback =
|
||||
parts.length < 3 ? this.#rawExpected : `${parts.slice(0, -1).join(", ")} or ${parts[parts.length - 1]}`;
|
||||
return format(this.#config?.expected, this.#context(fallback, actual), fallback);
|
||||
}
|
||||
|
||||
/** Short description of the received value, e.g. `"a number"` or `"missing"`. */
|
||||
get actual(): string {
|
||||
const actual = describeValue(this.data);
|
||||
return format(this.#config?.actual, this.#context(this.expected, actual), actual);
|
||||
const actual = describeValue(this.data, this.#config?.preserveActual ? "predicate" : this.code);
|
||||
const override = this.#config?.actual;
|
||||
return typeof override === "function" ? override(this.data) : (override ?? actual);
|
||||
}
|
||||
|
||||
/** Path-less problem statement: `must be <expected> (was <actual>)`. */
|
||||
@@ -77,17 +108,20 @@ export class OmpError {
|
||||
const expected = this.expected;
|
||||
const actual = this.actual;
|
||||
const fallback =
|
||||
this.data === MISSING ? `must be ${expected} (was missing)` : `must be ${expected} (was ${actual})`;
|
||||
this.data === MISSING
|
||||
? `must be ${expected} (was missing)`
|
||||
: actual === ""
|
||||
? `must be ${expected}`
|
||||
: `must be ${expected} (was ${actual})`;
|
||||
return format(this.#config?.problem, this.#context(expected, actual, fallback), fallback);
|
||||
}
|
||||
|
||||
/** Full message including the path prefix. */
|
||||
get message(): string {
|
||||
const expected = this.expected;
|
||||
const actual = this.actual;
|
||||
const problem = this.problem;
|
||||
const at = this.path.length === 0 ? "" : `${this.path.map(String).join(".")} `;
|
||||
return format(this.#config?.message, this.#context(expected, actual, problem), `${at}${problem}`);
|
||||
const at = this.path.length === 0 ? "" : `${formatPath(this.path)} `;
|
||||
const messageActual = this.#config?.actual === undefined ? describeKind(this.data) : actual;
|
||||
return format(this.#config?.message, this.#context(expected, messageActual, problem), `${at}${problem}`);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
@@ -97,8 +131,23 @@ export class OmpError {
|
||||
|
||||
/** Sentinel for a required key that was absent (distinguishes from `undefined`). */
|
||||
export const MISSING: unique symbol = Symbol("omptype.missing");
|
||||
function stringifyValue(data: object): string {
|
||||
const seen = new WeakSet<object>();
|
||||
return (
|
||||
JSON.stringify(data, (_key, value: unknown) => {
|
||||
if (typeof value !== "object" || value === null) return value;
|
||||
if (seen.has(value)) return "(cycle)";
|
||||
seen.add(value);
|
||||
return value;
|
||||
}) ?? "an object"
|
||||
);
|
||||
}
|
||||
|
||||
function describeValue(data: unknown): string {
|
||||
function describeValue(data: unknown, code: string): string {
|
||||
if (data === MISSING) return "missing";
|
||||
if (code === "undeclared") return "";
|
||||
if (code === "referenceSame") return "";
|
||||
if (code === "domain") return describeKind(data);
|
||||
if (data === null) return "null";
|
||||
if (Array.isArray(data)) return "an array";
|
||||
switch (typeof data) {
|
||||
@@ -113,7 +162,7 @@ function describeValue(data: unknown): string {
|
||||
case "undefined":
|
||||
return "undefined";
|
||||
case "object":
|
||||
return "an object";
|
||||
return stringifyValue(data);
|
||||
case "function":
|
||||
return "a function";
|
||||
default:
|
||||
@@ -121,23 +170,128 @@ function describeValue(data: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
function describeKind(data: unknown): string {
|
||||
if (data === MISSING) return "missing";
|
||||
if (typeof data === "number" && Number.isNaN(data)) return "NaN";
|
||||
if (data === null) return "null";
|
||||
if (Array.isArray(data)) return "an object";
|
||||
switch (typeof data) {
|
||||
case "string":
|
||||
return "a string";
|
||||
case "number":
|
||||
return "a number";
|
||||
case "bigint":
|
||||
return "a bigint";
|
||||
case "boolean":
|
||||
return "boolean";
|
||||
case "undefined":
|
||||
return "undefined";
|
||||
case "object": {
|
||||
const ctor = Object.getPrototypeOf(data)?.constructor;
|
||||
return typeof ctor?.name === "string" && ctor.name !== "Object" ? ctor.name : "an object";
|
||||
}
|
||||
case "function":
|
||||
return "a function";
|
||||
default:
|
||||
return "a symbol";
|
||||
}
|
||||
}
|
||||
|
||||
function describeExpectation(expected: string): { description: string; rule?: unknown } {
|
||||
const divisor = /divisible by (\d+(?:\.\d+)?)/.exec(expected);
|
||||
if (divisor) {
|
||||
const rule = Number(divisor[1]);
|
||||
return { description: rule === 2 ? "even" : `divisible by ${rule}`, rule };
|
||||
}
|
||||
return { description: expected.replace(/^(?:an?|the) /, "") };
|
||||
}
|
||||
|
||||
function formatPath(path: readonly PropertyKey[]): string {
|
||||
let out = "";
|
||||
for (const key of path) {
|
||||
if (typeof key === "number") out += `[${key}]`;
|
||||
else if (typeof key === "symbol") out += `[${String(key)}]`;
|
||||
else out += out.length === 0 ? key : `.${key}`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function errorCode(expected: string, data: unknown): string {
|
||||
if (data === MISSING) return "required";
|
||||
if (expected.includes("divisible by")) return "divisor";
|
||||
if (expected.includes("at least") || expected.includes("more than")) return "min";
|
||||
if (expected.includes("at most") || expected.includes("less than")) return "max";
|
||||
if (expected.includes("matching") || expected.includes("format") || expected.includes("email")) return "pattern";
|
||||
if (expected.includes("predicate") || expected.includes("satisfying")) return "predicate";
|
||||
if (expected === "removed") return "undeclared";
|
||||
if (expected.includes("serialized to the same value")) return "referenceSame";
|
||||
if (expected.includes("divisible by") || expected.includes("integer")) return "divisor";
|
||||
if (
|
||||
expected === "true" ||
|
||||
expected === "false" ||
|
||||
expected === "null" ||
|
||||
expected === "undefined" ||
|
||||
expected === "NaN" ||
|
||||
expected === "Infinity" ||
|
||||
expected === "-Infinity" ||
|
||||
/^-?\d+(?:\.\d+)?n?$/.test(expected) ||
|
||||
(expected.includes(" or ") &&
|
||||
expected.split(" or ").every(part => /^".*"$|^-?\d+(?:\.\d+)?n?$|^(?:true|false|null|undefined)$/.test(part)))
|
||||
) {
|
||||
return "unit";
|
||||
}
|
||||
if (
|
||||
expected.includes("at least") ||
|
||||
expected.includes("more than") ||
|
||||
expected.includes("timestamp after") ||
|
||||
expected === "non-negative" ||
|
||||
expected === "positive"
|
||||
) {
|
||||
return "min";
|
||||
}
|
||||
if (
|
||||
expected.includes("at most") ||
|
||||
expected.includes("less than") ||
|
||||
expected.includes("timestamp before") ||
|
||||
expected === "non-positive" ||
|
||||
expected === "negative"
|
||||
) {
|
||||
return "max";
|
||||
}
|
||||
if (
|
||||
expected.includes("matching") ||
|
||||
expected.includes("format") ||
|
||||
expected.includes("email") ||
|
||||
expected.includes("parsable") ||
|
||||
expected.includes("only digits")
|
||||
) {
|
||||
return "pattern";
|
||||
}
|
||||
if (expected.includes("predicate") || expected.includes("satisfying") || expected.includes("according to"))
|
||||
return "predicate";
|
||||
if (expected.startsWith('"') || expected.startsWith("the date ")) return "unit";
|
||||
return "domain";
|
||||
if (expected === "an Error" || expected === "a Date" || expected.startsWith("an instance of ")) return "domain";
|
||||
if (
|
||||
(expected.includes(" or ") && !expected.includes("IPv")) ||
|
||||
expected.endsWith(" instance") ||
|
||||
expected.startsWith("a number representing") ||
|
||||
[
|
||||
"a string",
|
||||
"a number",
|
||||
"a bigint",
|
||||
"a symbol",
|
||||
"boolean",
|
||||
"an object",
|
||||
"an array",
|
||||
"a tuple",
|
||||
"undefined",
|
||||
"null",
|
||||
"unknown",
|
||||
"never",
|
||||
].includes(expected)
|
||||
)
|
||||
return "domain";
|
||||
return "predicate";
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-failure validation result with a lazy array-like entry.
|
||||
*
|
||||
* Validators fast-fail, so allocating an `Array` subclass and a separate entry
|
||||
* on every rejection only penalizes callers that inspect errors by identity.
|
||||
* Indexing, iteration, and `map` materialize the entry on demand.
|
||||
* Validation failure result. The common single-error case remains lazy;
|
||||
* traversal only materializes an entry array when a second error is appended.
|
||||
*/
|
||||
type StoredPath = PropertyKey[] | PropertyKey | undefined;
|
||||
|
||||
@@ -146,10 +300,9 @@ export class OmpErrors implements Iterable<OmpError> {
|
||||
#expected: string;
|
||||
#data: unknown;
|
||||
#entry: OmpError | undefined;
|
||||
#entries: OmpError[] | undefined;
|
||||
#config: ErrorConfig | undefined;
|
||||
|
||||
/** Number of failures; omptype validators fast-fail on the first error. */
|
||||
readonly length = 1;
|
||||
#separator = "\n";
|
||||
|
||||
constructor(path: StoredPath, expected: string, data: unknown, config?: ErrorConfig) {
|
||||
this.#path = path;
|
||||
@@ -158,9 +311,12 @@ export class OmpErrors implements Iterable<OmpError> {
|
||||
this.#config = config;
|
||||
}
|
||||
|
||||
/** First and only validation failure, materialized on demand. */
|
||||
get length(): number {
|
||||
return this.#entries?.length ?? 1;
|
||||
}
|
||||
|
||||
get 0(): OmpError {
|
||||
return this.#getEntry();
|
||||
return this.#entries?.[0] ?? this.#getEntry();
|
||||
}
|
||||
|
||||
static single(path: PropertyKey[], expected: string, data: unknown, config?: ErrorConfig): OmpErrors {
|
||||
@@ -175,53 +331,95 @@ export class OmpErrors implements Iterable<OmpError> {
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** Prefix the failure path with `key` when nesting sub-schemas. */
|
||||
/** Append all failures from `other`, preserving traversal order. */
|
||||
append(other: OmpErrors): this {
|
||||
this.#entries ??= [this.#getEntry()];
|
||||
const entries = this.#entries;
|
||||
for (const entry of other) entries.push(entry);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Prefix every failure path with `key` when nesting sub-schemas. */
|
||||
prefix(key: PropertyKey): this {
|
||||
const path = this.#path;
|
||||
this.#path = path === undefined ? [key] : Array.isArray(path) ? [key, ...path] : [key, path];
|
||||
this.#entry = undefined;
|
||||
if (this.#entries) {
|
||||
for (const entry of this.#entries) entry.prefix(key);
|
||||
} else {
|
||||
const path = this.#path;
|
||||
this.#path = path === undefined ? [key] : Array.isArray(path) ? [key, ...path] : [key, path];
|
||||
this.#entry = undefined;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Apply schema-local message formatting without rebuilding the failure. */
|
||||
/** Apply schema-local message formatting without rebuilding failures. */
|
||||
configure(config: ErrorConfig): this {
|
||||
this.#config = { ...this.#config, ...config };
|
||||
this.#entry = undefined;
|
||||
if (this.#entries) {
|
||||
for (const entry of this.#entries) entry.configure(config);
|
||||
} else {
|
||||
this.#config = { ...this.#config, ...config };
|
||||
this.#entry = undefined;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Index the failure by its dotted property path (`""` for the root). */
|
||||
get byPath(): Readonly<Record<string, OmpError>> {
|
||||
const entry = this.#getEntry();
|
||||
return { [entry.path.map(String).join(".")]: entry };
|
||||
const result: Record<string, OmpError> = {};
|
||||
for (const entry of this) result[entry.path.map(String).join(".")] = entry;
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Transform the failure entry into a plain array. */
|
||||
map<result>(fn: (error: OmpError, index: number, errors: OmpErrors) => result): result[] {
|
||||
return [fn(this.#getEntry(), 0, this)];
|
||||
const result: result[] = [];
|
||||
let index = 0;
|
||||
for (const entry of this) result.push(fn(entry, index++, this));
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Select the failure entry into a plain array. */
|
||||
filter(fn: (error: OmpError, index: number, errors: OmpErrors) => unknown): OmpError[] {
|
||||
const entry = this.#getEntry();
|
||||
return fn(entry, 0, this) ? [entry] : [];
|
||||
const result: OmpError[] = [];
|
||||
let index = 0;
|
||||
for (const entry of this) {
|
||||
if (fn(entry, index++, this)) result.push(entry);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Iterate over the single failure entry. */
|
||||
*[Symbol.iterator](): IterableIterator<OmpError> {
|
||||
yield this.#getEntry();
|
||||
if (this.#entries) {
|
||||
yield* this.#entries;
|
||||
} else {
|
||||
yield this.#getEntry();
|
||||
}
|
||||
}
|
||||
|
||||
/** Human-readable failure text, materialized only when requested. */
|
||||
/** @internal Render multiple branch failures as alternatives rather than independent failures. */
|
||||
asAlternatives(): this {
|
||||
this.#separator = " or ";
|
||||
return this;
|
||||
}
|
||||
get summary(): string {
|
||||
return this.#getEntry().message;
|
||||
const entries = [...this];
|
||||
if (
|
||||
this.#separator === "\n" &&
|
||||
entries.length > 1 &&
|
||||
entries.every(
|
||||
entry =>
|
||||
Object.is(entry.data, entries[0].data) &&
|
||||
entry.path.length === entries[0].path.length &&
|
||||
entry.path.every((key, index) => key === entries[0].path[index]),
|
||||
)
|
||||
) {
|
||||
const at = formatPath(entries[0].path);
|
||||
const actual = typeof entries[0].data === "string" ? JSON.stringify(entries[0].data) : String(entries[0].data);
|
||||
return `${at}${at === "" ? "" : " "}(${actual}) must be...\n${entries.map(entry => ` ◦ ${entry.expected}`).join("\n")}`;
|
||||
}
|
||||
return entries.map(error => error.message).join(this.#separator);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.summary;
|
||||
}
|
||||
|
||||
/** Throw a `TraversalError` carrying this result. */
|
||||
throw(): never {
|
||||
throw new TraversalError(this);
|
||||
}
|
||||
|
||||
@@ -13,20 +13,42 @@ type Trim<s extends string> = TrimLeft<TrimRight<s>>;
|
||||
* `never` is intentionally absent: the parser rejects it and the fallback in
|
||||
* `InferMember` treats a missing entry as "not a primitive".
|
||||
*/
|
||||
type ArkAny = ReturnType<typeof JSON.parse>;
|
||||
|
||||
interface PrimitiveMap {
|
||||
string: string;
|
||||
"string.url": string;
|
||||
number: number;
|
||||
"number.integer": number;
|
||||
"number.epoch": number;
|
||||
"number.safe": number;
|
||||
"number.NaN": number;
|
||||
"number.Infinity": number;
|
||||
"number.NegativeInfinity": number;
|
||||
boolean: boolean;
|
||||
null: null;
|
||||
undefined: undefined;
|
||||
unknown: unknown;
|
||||
"unknown.any": ArkAny;
|
||||
any: unknown;
|
||||
object: object;
|
||||
bigint: bigint;
|
||||
symbol: symbol;
|
||||
Key: PropertyKey;
|
||||
Date: Date;
|
||||
Array: unknown[];
|
||||
// biome-ignore lint/complexity/noBannedTypes: built-in Function keyword
|
||||
Function: Function;
|
||||
RegExp: RegExp;
|
||||
File: File;
|
||||
Error: Error;
|
||||
Set: Set<unknown>;
|
||||
Map: Map<unknown, unknown>;
|
||||
WeakSet: WeakSet<WeakKey>;
|
||||
WeakMap: WeakMap<WeakKey, unknown>;
|
||||
Promise: Promise<unknown>;
|
||||
FormData: FormData;
|
||||
"object.json": unknown;
|
||||
true: true;
|
||||
false: false;
|
||||
}
|
||||
@@ -39,7 +61,7 @@ type Merge<left, right> = left extends object
|
||||
|
||||
type InferUtility<s extends string> = s extends `Record<${string},${infer value}>`
|
||||
? Record<string, InferString<value>>
|
||||
: s extends `Array<${infer element}>`
|
||||
: s extends `Array<${infer element}>` | `Array.liftFrom<${infer element}>`
|
||||
? InferString<element>[]
|
||||
: s extends `Partial<${infer value}>`
|
||||
? Partial<InferString<value>>
|
||||
@@ -66,11 +88,15 @@ type InferParse<s extends string> = s extends
|
||||
? URL
|
||||
: s extends "string.json.parse" | "parse.json"
|
||||
? unknown
|
||||
: s extends "parse.boolean"
|
||||
? boolean
|
||||
: s extends "parse.bigint"
|
||||
? bigint
|
||||
: never;
|
||||
: s extends "object.json.stringify"
|
||||
? string
|
||||
: s extends "FormData.parse"
|
||||
? Record<string, Bun.FormDataEntryValue | Bun.FormDataEntryValue[]>
|
||||
: s extends "parse.boolean"
|
||||
? boolean
|
||||
: s extends "parse.bigint"
|
||||
? bigint
|
||||
: never;
|
||||
|
||||
/**
|
||||
* Member inference as a flat false-branch chain: TypeScript tail-evaluates
|
||||
@@ -259,11 +285,21 @@ type InferObjectIn<def extends object> = "[string]" extends keyof def
|
||||
/** Object-literal inference used by fluent composition overloads. */
|
||||
export type InferObjectDef<def extends object> = InferObject<def>;
|
||||
|
||||
type InferLiteralDef<def> = def extends string
|
||||
? InferString<def>
|
||||
: def extends object
|
||||
? InferObjectLiteral<def>
|
||||
: unknown;
|
||||
type InferLiteralDef<def> = def extends { readonly infer: infer output }
|
||||
? output
|
||||
: def extends string
|
||||
? InferString<def>
|
||||
: def extends object
|
||||
? InferObjectLiteral<def>
|
||||
: unknown;
|
||||
|
||||
type InferLiteralDefIn<def> = def extends { readonly inferIn: infer input }
|
||||
? input
|
||||
: def extends string
|
||||
? InferStringIn<def>
|
||||
: def extends object
|
||||
? InferObjectLiteralIn<def>
|
||||
: unknown;
|
||||
|
||||
type LiteralRequired<def extends object> = {
|
||||
-readonly [key in DefinitionKeys<def> as IsOptionalProp<key, def[key]> extends true
|
||||
@@ -281,9 +317,28 @@ type LiteralOptional<def extends object> = {
|
||||
: never]?: InferLiteralDef<UnwrapProperty<def[key]>>;
|
||||
};
|
||||
|
||||
/** Object-literal-only inference that does not inspect embedded schema internals. */
|
||||
type LiteralRequiredIn<def extends object> = {
|
||||
-readonly [key in DefinitionKeys<def> as IsOptionalProp<key, def[key]> extends true
|
||||
? never
|
||||
: HasDefault<def[key]> extends true
|
||||
? never
|
||||
: PropName<key>]-?: InferLiteralDefIn<UnwrapProperty<def[key]>>;
|
||||
};
|
||||
|
||||
type LiteralOptionalIn<def extends object> = {
|
||||
-readonly [key in DefinitionKeys<def> as IsOptionalProp<key, def[key]> extends true
|
||||
? PropName<key>
|
||||
: HasDefault<def[key]> extends true
|
||||
? PropName<key>
|
||||
: never]?: InferLiteralDefIn<UnwrapProperty<def[key]>>;
|
||||
};
|
||||
|
||||
/** Object-literal inference that unwraps embedded schema values one level deep. */
|
||||
export type InferObjectLiteral<def extends object> = Simplify<LiteralRequired<def> & LiteralOptional<def>>;
|
||||
|
||||
/** Input-side object-literal inference (embedded schemas contribute `inferIn`). */
|
||||
export type InferObjectLiteralIn<def extends object> = Simplify<LiteralRequiredIn<def> & LiteralOptionalIn<def>>;
|
||||
|
||||
type InstanceOf<ctor> = ctor extends abstract new (...args: never[]) => infer instance ? instance : never;
|
||||
type SpreadOutput<def> = InferDef<def> extends readonly (infer element)[] ? element[] : never[];
|
||||
type SpreadInput<def> = InferDefIn<def> extends readonly (infer element)[] ? element[] : never[];
|
||||
|
||||
+461
-122
@@ -9,15 +9,36 @@
|
||||
* - failure returns an `OmpErrors` with a single fast-fail entry
|
||||
*/
|
||||
import { MISSING, OmpErrors } from "./errors";
|
||||
import { expectedOf, hasMorph, type IR } from "./ir";
|
||||
import { expectedOf, hasAlias, hasMorph, type IR } from "./ir";
|
||||
|
||||
const own = Object.prototype.hasOwnProperty;
|
||||
/** Return an independent runtime value for a prevalidated static default. */
|
||||
export function materializeDefault(payload: unknown): unknown {
|
||||
if (payload === null || typeof payload !== "object") return payload;
|
||||
if (payload instanceof Date) return new Date(payload);
|
||||
return structuredClone(payload);
|
||||
}
|
||||
let activeVisits: WeakMap<object, Set<IR>> | undefined;
|
||||
let activeChecks: WeakMap<object, Set<IR>> | undefined;
|
||||
|
||||
/** Validate `value` against `ir`; returns output value or `OmpErrors`. */
|
||||
export function walk(ir: IR, value: unknown): unknown {
|
||||
const path: PropertyKey[] = [];
|
||||
const out = visit(ir, value, path);
|
||||
return out;
|
||||
/**
|
||||
* Validate `value` against `ir`; returns output value or `OmpErrors`.
|
||||
* `path` seeds the traversal location so nested step callbacks observe
|
||||
* absolute ctx.path values when a compiled parent delegates a subtree;
|
||||
* resulting error paths are then already absolute.
|
||||
*/
|
||||
export function walk(ir: IR, value: unknown, path: PropertyKey[] = []): unknown {
|
||||
const previousVisits = activeVisits;
|
||||
const previousChecks = activeChecks;
|
||||
// Created lazily by visit/checks only when a recursive-alias node is reached.
|
||||
activeVisits = undefined;
|
||||
activeChecks = undefined;
|
||||
try {
|
||||
return visit(ir, value, path);
|
||||
} finally {
|
||||
activeVisits = previousVisits;
|
||||
activeChecks = previousChecks;
|
||||
}
|
||||
}
|
||||
|
||||
function fail(path: PropertyKey[], expected: string, data: unknown): OmpErrors {
|
||||
@@ -27,6 +48,26 @@ function fail(path: PropertyKey[], expected: string, data: unknown): OmpErrors {
|
||||
|
||||
/** Pure predicate used for union-member scanning (no morphs, no errors). */
|
||||
function checks(ir: IR, v: unknown): boolean {
|
||||
// Cycle guards are only needed when the subtree can revisit nodes through
|
||||
// recursive aliases; plain schemas skip the WeakMap bookkeeping entirely.
|
||||
if (typeof v !== "object" || v === null || !hasAlias(ir)) return checkNode(ir, v);
|
||||
activeChecks ??= new WeakMap();
|
||||
const visits = activeChecks;
|
||||
let visited = visits.get(v);
|
||||
if (visited?.has(ir)) return true;
|
||||
if (visited === undefined) {
|
||||
visited = new Set();
|
||||
visits.set(v, visited);
|
||||
}
|
||||
visited.add(ir);
|
||||
try {
|
||||
return checkNode(ir, v);
|
||||
} finally {
|
||||
visited.delete(ir);
|
||||
}
|
||||
}
|
||||
|
||||
function checkNode(ir: IR, v: unknown): boolean {
|
||||
switch (ir.k) {
|
||||
case "unknown":
|
||||
return true;
|
||||
@@ -69,7 +110,7 @@ function checks(ir: IR, v: unknown): boolean {
|
||||
if (!Array.isArray(v)) return false;
|
||||
if (ir.min !== undefined && v.length < ir.min) return false;
|
||||
if (ir.max !== undefined && v.length > ir.max) return false;
|
||||
for (const el of v) if (!checks(ir.el, el)) return false;
|
||||
for (const element of v) if (!checks(ir.el, element)) return false;
|
||||
return true;
|
||||
}
|
||||
case "tuple": {
|
||||
@@ -98,8 +139,8 @@ function checks(ir: IR, v: unknown): boolean {
|
||||
return true;
|
||||
}
|
||||
case "object": {
|
||||
if (typeof v !== "object" || v === null || Array.isArray(v)) return false;
|
||||
const rec = v as Record<string, unknown>;
|
||||
if (typeof v !== "object" || v === null) return false;
|
||||
const rec = v as Record<PropertyKey, unknown>;
|
||||
for (const p of ir.props) {
|
||||
const present = p.key in rec;
|
||||
if (!present) {
|
||||
@@ -108,21 +149,31 @@ function checks(ir: IR, v: unknown): boolean {
|
||||
}
|
||||
if (!checks(p.val, rec[p.key])) return false;
|
||||
}
|
||||
if (ir.index) {
|
||||
for (const key in rec) {
|
||||
if (own.call(rec, key) && !checks(ir.index, rec[key])) return false;
|
||||
}
|
||||
} else if (ir.extras === "reject") {
|
||||
for (const key in rec) {
|
||||
if (!own.call(rec, key)) continue;
|
||||
let declared = false;
|
||||
for (const p of ir.props) {
|
||||
if (p.key === key) {
|
||||
declared = true;
|
||||
break;
|
||||
}
|
||||
for (const key in rec) {
|
||||
if (!own.call(rec, key)) continue;
|
||||
if (ir.index !== undefined && !checks(ir.index, rec[key])) return false;
|
||||
let patternMatched = false;
|
||||
if (ir.patternIndexes !== undefined) {
|
||||
for (const pattern of ir.patternIndexes) {
|
||||
if (!checks(pattern.key, key)) continue;
|
||||
patternMatched = true;
|
||||
if (!checks(pattern.val, rec[key])) return false;
|
||||
}
|
||||
if (!declared) return false;
|
||||
}
|
||||
if (
|
||||
ir.extras === "reject" &&
|
||||
ir.index === undefined &&
|
||||
!patternMatched &&
|
||||
!ir.props.some(prop => prop.key === key)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (const key of Object.getOwnPropertySymbols(rec)) {
|
||||
if (!Object.prototype.propertyIsEnumerable.call(rec, key)) continue;
|
||||
if (ir.symbolIndex !== undefined && !checks(ir.symbolIndex, rec[key])) return false;
|
||||
if (ir.extras === "reject" && ir.symbolIndex === undefined && !ir.props.some(prop => prop.key === key)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
@@ -132,7 +183,7 @@ function checks(ir: IR, v: unknown): boolean {
|
||||
case "refine":
|
||||
if (!checks(ir.base, v)) return false;
|
||||
try {
|
||||
return ir.pred(v);
|
||||
return ir.pred(v) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -146,6 +197,34 @@ function checks(ir: IR, v: unknown): boolean {
|
||||
}
|
||||
|
||||
function visit(ir: IR, v: unknown, path: PropertyKey[]): unknown {
|
||||
if (typeof v !== "object" || v === null || !hasAlias(ir)) return visitFinish(ir, v, path);
|
||||
activeVisits ??= new WeakMap();
|
||||
const visits = activeVisits;
|
||||
let visited = visits.get(v);
|
||||
if (visited?.has(ir)) return v;
|
||||
if (visited === undefined) {
|
||||
visited = new Set();
|
||||
visits.set(v, visited);
|
||||
}
|
||||
visited.add(ir);
|
||||
try {
|
||||
return visitFinish(ir, v, path);
|
||||
} finally {
|
||||
visited.delete(ir);
|
||||
}
|
||||
}
|
||||
|
||||
/** Run the node visitor, then apply node-local error configuration. */
|
||||
function visitFinish(ir: IR, v: unknown, path: PropertyKey[]): unknown {
|
||||
const out = visitNode(ir, v, path);
|
||||
if (!(out instanceof OmpErrors) || ir.cfg === undefined) return out;
|
||||
for (const error of out) {
|
||||
if (error.path.length !== path.length) return out;
|
||||
}
|
||||
return out.configure(ir.cfg);
|
||||
}
|
||||
|
||||
function visitNode(ir: IR, v: unknown, path: PropertyKey[]): unknown {
|
||||
switch (ir.k) {
|
||||
case "alias":
|
||||
return visit(ir.resolve(), v, path);
|
||||
@@ -153,7 +232,9 @@ function visit(ir: IR, v: unknown, path: PropertyKey[]): unknown {
|
||||
const base = visit(ir.base, v, path);
|
||||
if (base instanceof OmpErrors) return base;
|
||||
try {
|
||||
return ir.pred(base) ? base : fail(path, ir.expected, base);
|
||||
const result = ir.pred(base);
|
||||
if (result instanceof OmpErrors) return path.length === 0 ? result : prefixAll(result, path);
|
||||
return result ? base : fail(path, ir.expected, base);
|
||||
} catch {
|
||||
return fail(path, ir.expected, base);
|
||||
}
|
||||
@@ -178,7 +259,7 @@ function visit(ir: IR, v: unknown, path: PropertyKey[]): unknown {
|
||||
return output;
|
||||
}
|
||||
case "sub": {
|
||||
const out = ir.schema.run(v);
|
||||
const out = ir.schema.run(v, path);
|
||||
if (out instanceof OmpErrors) {
|
||||
return path.length === 0 ? out : prefixAll(out, path);
|
||||
}
|
||||
@@ -192,38 +273,40 @@ function visit(ir: IR, v: unknown, path: PropertyKey[]): unknown {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
for (const m of ir.members) {
|
||||
if (m.k === "sub" || hasMorph(m)) {
|
||||
const out = visit(m, v, path);
|
||||
let targeted: OmpErrors | undefined;
|
||||
let targetCount = 0;
|
||||
for (const member of ir.members) {
|
||||
if (member.k === "sub" || hasMorph(member)) {
|
||||
const out = visit(member, v, path);
|
||||
if (!(out instanceof OmpErrors)) return out;
|
||||
if (kindMatches(unwrapBase(member), v)) {
|
||||
targetCount++;
|
||||
targeted ??= out;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (targetCount === 1 && targeted !== undefined) return targeted;
|
||||
return ir.members.some(canRefineUnionFailure) ? unionFail(ir, v, path) : fail(path, expectedOf(ir), v);
|
||||
}
|
||||
case "array": {
|
||||
if (!Array.isArray(v)) return fail(path, "an array", v);
|
||||
if (ir.min !== undefined && v.length < ir.min) return fail(path, `at least length ${ir.min}`, v);
|
||||
if (ir.max !== undefined && v.length > ir.max) return fail(path, `at most length ${ir.max}`, v);
|
||||
if (!hasMorph(ir.el)) {
|
||||
for (let i = 0; i < v.length; i++) {
|
||||
if (!checks(ir.el, v[i])) {
|
||||
path.push(i);
|
||||
const err = visit(ir.el, v[i], path);
|
||||
path.pop();
|
||||
return err instanceof OmpErrors ? err : fail([...path, i], expectedOf(ir.el), v[i]);
|
||||
}
|
||||
}
|
||||
return v;
|
||||
}
|
||||
const out = new Array<unknown>(v.length);
|
||||
for (let i = 0; i < v.length; i++) {
|
||||
path.push(i);
|
||||
const el = visit(ir.el, v[i], path);
|
||||
if (ir.min !== undefined && v.length < ir.min) return fail(path, `at least length ${ir.min}`, v.length);
|
||||
if (ir.max !== undefined && v.length > ir.max) return fail(path, `at most length ${ir.max}`, v.length);
|
||||
const morph = hasMorph(ir.el);
|
||||
const out = morph ? new Array<unknown>(v.length) : v;
|
||||
let errors: OmpErrors | undefined;
|
||||
for (let index = 0; index < v.length; index++) {
|
||||
path.push(index);
|
||||
const element = visit(ir.el, v[index], path);
|
||||
path.pop();
|
||||
if (el instanceof OmpErrors) return el;
|
||||
out[i] = el;
|
||||
if (element instanceof OmpErrors) {
|
||||
if (errors) errors.append(element);
|
||||
else errors = element;
|
||||
} else if (morph) {
|
||||
out[index] = element;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
return errors ?? out;
|
||||
}
|
||||
case "tuple": {
|
||||
if (!Array.isArray(v)) return fail(path, "an array", v);
|
||||
@@ -238,23 +321,41 @@ function visit(ir: IR, v: unknown, path: PropertyKey[]): unknown {
|
||||
const prefixCount = Math.min(ir.prefix.length, postfixStart);
|
||||
const morph = hasMorph(ir);
|
||||
const output = morph ? [...v] : v;
|
||||
let errors: OmpErrors | undefined;
|
||||
for (let index = 0; index < prefixCount; index++) {
|
||||
path.push(index);
|
||||
const item = visit(ir.prefix[index].val, v[index], path);
|
||||
path.pop();
|
||||
if (item instanceof OmpErrors) return item;
|
||||
if (morph) output[index] = item;
|
||||
if (item instanceof OmpErrors) {
|
||||
if (errors) errors.append(item);
|
||||
else errors = item;
|
||||
} else if (morph) {
|
||||
output[index] = item;
|
||||
}
|
||||
}
|
||||
for (let index = prefixCount; index < ir.prefix.length; index++) {
|
||||
const item = ir.prefix[index];
|
||||
if (item.hasDefault && morph) {
|
||||
const payload = item.def;
|
||||
output[index] = item.defFactory && typeof payload === "function" ? payload() : payload;
|
||||
if (item.defFactory && typeof payload === "function") {
|
||||
path.push(index);
|
||||
const resolved = visit(item.val, payload(), path);
|
||||
path.pop();
|
||||
if (resolved instanceof OmpErrors) {
|
||||
if (errors) errors.append(resolved);
|
||||
else errors = resolved;
|
||||
} else {
|
||||
output[index] = resolved;
|
||||
}
|
||||
} else {
|
||||
output[index] = materializeDefault(payload);
|
||||
}
|
||||
} else if (!item.opt) {
|
||||
path.push(index);
|
||||
const error = fail(path, expectedOf(item.val), MISSING);
|
||||
path.pop();
|
||||
return error;
|
||||
if (errors) errors.append(error);
|
||||
else errors = error;
|
||||
}
|
||||
}
|
||||
if (ir.variadic !== undefined) {
|
||||
@@ -262,8 +363,12 @@ function visit(ir: IR, v: unknown, path: PropertyKey[]): unknown {
|
||||
path.push(index);
|
||||
const item = visit(ir.variadic, v[index], path);
|
||||
path.pop();
|
||||
if (item instanceof OmpErrors) return item;
|
||||
if (morph) output[index] = item;
|
||||
if (item instanceof OmpErrors) {
|
||||
if (errors) errors.append(item);
|
||||
else errors = item;
|
||||
} else if (morph) {
|
||||
output[index] = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let index = 0; index < ir.postfix.length; index++) {
|
||||
@@ -271,18 +376,28 @@ function visit(ir: IR, v: unknown, path: PropertyKey[]): unknown {
|
||||
path.push(inputIndex);
|
||||
const item = visit(ir.postfix[index], v[inputIndex], path);
|
||||
path.pop();
|
||||
if (item instanceof OmpErrors) return item;
|
||||
if (morph) output[inputIndex] = item;
|
||||
if (item instanceof OmpErrors) {
|
||||
if (errors) errors.append(item);
|
||||
else errors = item;
|
||||
} else if (morph) {
|
||||
output[inputIndex] = item;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
return errors ?? output;
|
||||
}
|
||||
case "object": {
|
||||
if (typeof v !== "object" || v === null || Array.isArray(v)) return fail(path, "an object", v);
|
||||
const rec = v as Record<string, unknown>;
|
||||
if (typeof v !== "object" || v === null) return fail(path, "an object", v);
|
||||
const rec = v as Record<PropertyKey, unknown>;
|
||||
const morph = hasMorph(ir);
|
||||
let out: Record<string, unknown> | undefined;
|
||||
let out: Record<PropertyKey, unknown> | undefined;
|
||||
let errors: OmpErrors | undefined;
|
||||
if (morph) {
|
||||
if (ir.extras === "delete" && !ir.index) {
|
||||
if (
|
||||
ir.extras === "delete" &&
|
||||
ir.index === undefined &&
|
||||
ir.symbolIndex === undefined &&
|
||||
ir.patternIndexes === undefined
|
||||
) {
|
||||
out = {};
|
||||
} else {
|
||||
out = { ...rec };
|
||||
@@ -291,51 +406,155 @@ function visit(ir: IR, v: unknown, path: PropertyKey[]): unknown {
|
||||
for (const p of ir.props) {
|
||||
if (!(p.key in rec)) {
|
||||
if (p.hasDefault && out) {
|
||||
// defFactory guarantees a callable default payload
|
||||
const payload = p.def;
|
||||
out[p.key] = p.defFactory && typeof payload === "function" ? payload() : payload;
|
||||
if (p.defFactory && typeof payload === "function") {
|
||||
path.push(p.key);
|
||||
const resolved = visit(p.val, payload(), path);
|
||||
path.pop();
|
||||
if (resolved instanceof OmpErrors) {
|
||||
if (errors) errors.append(resolved);
|
||||
else errors = resolved;
|
||||
} else {
|
||||
out[p.key] = resolved;
|
||||
}
|
||||
} else {
|
||||
out[p.key] = materializeDefault(payload);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (p.opt || p.hasDefault) continue;
|
||||
path.push(p.key);
|
||||
const err = fail(path, expectedOf(p.val), MISSING);
|
||||
const error = fail(path, expectedOf(p.val), MISSING);
|
||||
path.pop();
|
||||
return err;
|
||||
if (errors) errors.append(error);
|
||||
else errors = error;
|
||||
continue;
|
||||
}
|
||||
path.push(p.key);
|
||||
const res = visit(p.val, rec[p.key], path);
|
||||
const result = visit(p.val, rec[p.key], path);
|
||||
path.pop();
|
||||
if (res instanceof OmpErrors) return res;
|
||||
if (out) out[p.key] = res;
|
||||
}
|
||||
if (ir.index) {
|
||||
for (const key in rec) {
|
||||
if (!own.call(rec, key)) continue;
|
||||
path.push(key);
|
||||
const res = visit(ir.index, rec[key], path);
|
||||
path.pop();
|
||||
if (res instanceof OmpErrors) return res;
|
||||
if (out) out[key] = res;
|
||||
if (result instanceof OmpErrors) {
|
||||
if (errors) errors.append(result);
|
||||
else errors = result;
|
||||
} else if (out) {
|
||||
out[p.key] = result;
|
||||
}
|
||||
} else if (ir.extras === "reject") {
|
||||
for (const key in rec) {
|
||||
if (!own.call(rec, key)) continue;
|
||||
let declared = false;
|
||||
for (const p of ir.props) {
|
||||
if (p.key === key) {
|
||||
declared = true;
|
||||
break;
|
||||
}
|
||||
for (const key in rec) {
|
||||
if (!own.call(rec, key)) continue;
|
||||
let indexed = false;
|
||||
if (ir.index !== undefined) {
|
||||
indexed = true;
|
||||
path.push(key);
|
||||
const result = visit(ir.index, rec[key], path);
|
||||
path.pop();
|
||||
if (result instanceof OmpErrors) {
|
||||
if (errors) errors.append(result);
|
||||
else errors = result;
|
||||
} else if (out) {
|
||||
out[key] = result;
|
||||
}
|
||||
}
|
||||
if (ir.patternIndexes !== undefined) {
|
||||
for (const pattern of ir.patternIndexes) {
|
||||
if (!checks(pattern.key, key)) continue;
|
||||
indexed = true;
|
||||
path.push(key);
|
||||
const result = visit(pattern.val, rec[key], path);
|
||||
path.pop();
|
||||
if (result instanceof OmpErrors) {
|
||||
if (errors) errors.append(result);
|
||||
else errors = result;
|
||||
} else if (out) {
|
||||
out[key] = result;
|
||||
}
|
||||
}
|
||||
if (!declared) {
|
||||
path.push(key);
|
||||
const err = fail(path, "removed (undeclared key)", rec[key]);
|
||||
path.pop();
|
||||
return err;
|
||||
}
|
||||
}
|
||||
if (ir.extras === "reject" && !indexed && !ir.props.some(prop => prop.key === key)) {
|
||||
path.push(key);
|
||||
const error = fail(path, "removed", rec[key]);
|
||||
path.pop();
|
||||
if (errors) errors.append(error);
|
||||
else errors = error;
|
||||
}
|
||||
}
|
||||
return out ?? v;
|
||||
for (const key of Object.getOwnPropertySymbols(rec)) {
|
||||
if (!Object.prototype.propertyIsEnumerable.call(rec, key)) continue;
|
||||
if (ir.symbolIndex !== undefined) {
|
||||
path.push(key);
|
||||
const result = visit(ir.symbolIndex, rec[key], path);
|
||||
path.pop();
|
||||
if (result instanceof OmpErrors) {
|
||||
if (errors) errors.append(result);
|
||||
else errors = result;
|
||||
} else if (out) {
|
||||
out[key] = result;
|
||||
}
|
||||
} else if (ir.extras === "reject" && !ir.props.some(prop => prop.key === key)) {
|
||||
path.push(key);
|
||||
const error = fail(path, "removed", rec[key]);
|
||||
path.pop();
|
||||
if (errors) errors.append(error);
|
||||
else errors = error;
|
||||
}
|
||||
}
|
||||
return errors ?? out ?? v;
|
||||
}
|
||||
case "string": {
|
||||
if (typeof v !== "string") return fail(path, "a string", v);
|
||||
if (ir.min !== undefined && v.length < ir.min) return fail(path, `at least length ${ir.min}`, v.length);
|
||||
if (ir.max !== undefined && v.length > ir.max) return fail(path, `at most length ${ir.max}`, v.length);
|
||||
if (ir.url && !URL.canParse(v)) return fail(path, "a URL string", v);
|
||||
return v;
|
||||
}
|
||||
case "number": {
|
||||
if (typeof v !== "number" || !Number.isFinite(v)) return fail(path, ir.int ? "an integer" : "a number", v);
|
||||
let errors: OmpErrors | undefined;
|
||||
const add = (expected: string): void => {
|
||||
const error = fail(path, expected, v);
|
||||
if (errors) errors.append(error);
|
||||
else errors = error;
|
||||
};
|
||||
if (ir.int && !Number.isInteger(v)) add("an integer");
|
||||
if (ir.divisor !== undefined && v % ir.divisor !== 0) add(`a number divisible by ${ir.divisor}`);
|
||||
if (ir.min !== undefined && (ir.xmin ? v <= ir.min : v < ir.min)) {
|
||||
add(
|
||||
ir.min === 0
|
||||
? ir.xmin
|
||||
? "positive"
|
||||
: "non-negative"
|
||||
: `a number ${ir.xmin ? "more than" : "at least"} ${ir.min}`,
|
||||
);
|
||||
}
|
||||
if (ir.max !== undefined && (ir.xmax ? v >= ir.max : v > ir.max)) {
|
||||
add(
|
||||
ir.max === 0
|
||||
? ir.xmax
|
||||
? "negative"
|
||||
: "non-positive"
|
||||
: `a number ${ir.xmax ? "less than" : "at most"} ${ir.max}`,
|
||||
);
|
||||
}
|
||||
return errors ?? v;
|
||||
}
|
||||
case "lit": {
|
||||
if (checks(ir, v)) return v;
|
||||
if ((typeof ir.v === "object" && ir.v !== null) || typeof ir.v === "function") {
|
||||
let expected = "the specified reference";
|
||||
try {
|
||||
const serialized = JSON.stringify(ir.v);
|
||||
if (serialized !== undefined) {
|
||||
expected = `reference equal to ${serialized}`;
|
||||
if (typeof v === "object" && v !== null && JSON.stringify(v) === serialized) {
|
||||
expected += " (serialized to the same value)";
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Cyclic values still get a useful reference-identity expectation.
|
||||
}
|
||||
return fail(path, expected, v);
|
||||
}
|
||||
return fail(path, expectedOf(ir), v);
|
||||
}
|
||||
default:
|
||||
return checks(ir, v) ? v : fail(path, expectedOf(ir), v);
|
||||
@@ -349,16 +568,7 @@ function prefixAll(errs: OmpErrors, path: PropertyKey[]): OmpErrors {
|
||||
|
||||
/** True when a union failure can be replaced with a more specific nested error. */
|
||||
export function canRefineUnionFailure(member: IR): boolean {
|
||||
const base = member.k === "sub" ? member.schema.ir : member;
|
||||
if (member.k === "sub") {
|
||||
return (
|
||||
base.k === "array" ||
|
||||
base.k === "object" ||
|
||||
base.k === "anyobject" ||
|
||||
base.k === "string" ||
|
||||
base.k === "number"
|
||||
);
|
||||
}
|
||||
const base = unwrapBase(member);
|
||||
if (base.k === "array" || base.k === "object") return true;
|
||||
if (base.k === "string") return base.min !== undefined || base.max !== undefined || base.url === true;
|
||||
return base.k === "number" && (base.int === true || base.min !== undefined || base.max !== undefined);
|
||||
@@ -373,39 +583,168 @@ export function canRefineUnionFailure(member: IR): boolean {
|
||||
*/
|
||||
export function unionFail(ir: IR & { k: "union" }, v: unknown, path: PropertyKey[], expected?: string): OmpErrors {
|
||||
let best: IR | undefined;
|
||||
for (const m of ir.members) {
|
||||
const base = m.k === "sub" ? m.schema.ir : m;
|
||||
for (const member of ir.members) {
|
||||
const base = unwrapBase(member);
|
||||
if (!kindMatches(base, v)) continue;
|
||||
if (best !== undefined) {
|
||||
best = undefined;
|
||||
break;
|
||||
}
|
||||
best = m;
|
||||
best = member;
|
||||
}
|
||||
if (best === undefined) best = discriminate(ir.members, v);
|
||||
if (best) {
|
||||
if (best === undefined) {
|
||||
const discriminated = discriminateFailure(ir.members, v, path);
|
||||
if (discriminated !== undefined) return discriminated;
|
||||
}
|
||||
if (best !== undefined) {
|
||||
const out = visit(best, v, path);
|
||||
if (out instanceof OmpErrors) return out;
|
||||
}
|
||||
if (ir.members.every(member => unwrapBase(member).k === "object")) {
|
||||
const branches = ir.members.flatMap(member => {
|
||||
const result = visit(member, v, path);
|
||||
return result instanceof OmpErrors ? [[...result]] : [];
|
||||
});
|
||||
if (branches.length !== 0) {
|
||||
const common = branches[0].filter(
|
||||
(entry, index, first) =>
|
||||
first.findIndex(candidate => pathsEqual(candidate.path, entry.path)) === index &&
|
||||
branches.every(branch => branch.some(candidate => pathsEqual(candidate.path, entry.path))),
|
||||
);
|
||||
const alternatives: OmpErrors[] = [];
|
||||
if (common.length !== 0) {
|
||||
for (const entry of common) {
|
||||
const expectations = new Set<string>();
|
||||
for (const branch of branches) {
|
||||
for (const candidate of branch) {
|
||||
if (pathsEqual(candidate.path, entry.path)) {
|
||||
expectations.add(candidate.expected.endsWith(" instance") ? "an object" : candidate.expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
alternatives.push(
|
||||
new OmpErrors(entry.path, [...expectations].join(" or "), entry.data, { preserveActual: true }),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
for (const branch of branches) {
|
||||
for (const entry of branch) {
|
||||
alternatives.push(
|
||||
new OmpErrors(
|
||||
entry.path,
|
||||
entry.expected.endsWith(" instance") ? "an object" : entry.expected,
|
||||
entry.data,
|
||||
{ preserveActual: true },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const combined = alternatives[0];
|
||||
for (let index = 1; index < alternatives.length; index++) combined.append(alternatives[index]);
|
||||
return alternatives.length === 1 ? combined : combined.asAlternatives();
|
||||
}
|
||||
}
|
||||
return fail(path, expected ?? expectedOf(ir), v);
|
||||
}
|
||||
|
||||
/** Pick the sole object member whose literal-typed property matches the value's. */
|
||||
function discriminate(members: IR[], v: unknown): IR | undefined {
|
||||
if (typeof v !== "object" || v === null || Array.isArray(v)) return undefined;
|
||||
const rec = v as Record<string, unknown>;
|
||||
let match: IR | undefined;
|
||||
for (const m of members) {
|
||||
const base = m.k === "sub" ? m.schema.ir : m;
|
||||
if (base.k !== "object") continue;
|
||||
for (const p of base.props) {
|
||||
if (p.val.k !== "lit" || rec[p.key] !== p.val.v) continue;
|
||||
if (match !== undefined) return undefined; // ambiguous
|
||||
match = m;
|
||||
break;
|
||||
interface LiteralDiscriminant {
|
||||
path: PropertyKey[];
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
function unwrapBase(member: IR, seen = new Set<IR>()): IR {
|
||||
if (seen.has(member)) return member;
|
||||
seen.add(member);
|
||||
if (member.k === "sub") return unwrapBase(member.schema.ir, seen);
|
||||
if (member.k === "alias") return unwrapBase(member.resolve(), seen);
|
||||
if (member.k === "refine") return unwrapBase(member.base, seen);
|
||||
return member;
|
||||
}
|
||||
|
||||
function collectDiscriminants(member: IR, prefix: PropertyKey[] = [], seen = new Set<IR>()): LiteralDiscriminant[] {
|
||||
if (seen.has(member)) return [];
|
||||
seen.add(member);
|
||||
if (member.k === "alias") return collectDiscriminants(member.resolve(), prefix, seen);
|
||||
if (member.k === "sub") return collectDiscriminants(member.schema.ir, prefix, seen);
|
||||
if (member.k === "refine") return collectDiscriminants(member.base, prefix, seen);
|
||||
if (member.k !== "object") return [];
|
||||
const result: LiteralDiscriminant[] = [];
|
||||
for (const property of member.props) {
|
||||
const propertyPath = [...prefix, property.key];
|
||||
const value = unwrapBase(property.val);
|
||||
if (value.k === "lit") result.push({ path: propertyPath, value: value.v });
|
||||
else result.push(...collectDiscriminants(property.val, propertyPath, new Set(seen)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function pathsEqual(left: readonly PropertyKey[], right: readonly PropertyKey[]): boolean {
|
||||
return left.length === right.length && left.every((key, index) => key === right[index]);
|
||||
}
|
||||
|
||||
function valueAtPath(value: unknown, path: readonly PropertyKey[]): { present: boolean; value?: unknown } {
|
||||
let cursor = value;
|
||||
for (const key of path) {
|
||||
if ((typeof cursor !== "object" && typeof cursor !== "function") || cursor === null || !(key in cursor)) {
|
||||
return { present: false };
|
||||
}
|
||||
cursor = (cursor as Record<PropertyKey, unknown>)[key];
|
||||
}
|
||||
return { present: true, value: cursor };
|
||||
}
|
||||
|
||||
function discriminateFailure(members: IR[], value: unknown, path: PropertyKey[]): OmpErrors | undefined {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
||||
const byMember = members.map(member => collectDiscriminants(member));
|
||||
const candidates: { path: PropertyKey[]; distinct: number; declared: number }[] = [];
|
||||
for (const discriminants of byMember) {
|
||||
for (const discriminant of discriminants) {
|
||||
if (candidates.some(candidate => pathsEqual(candidate.path, discriminant.path))) continue;
|
||||
const values: unknown[] = [];
|
||||
let declared = 0;
|
||||
for (const branch of byMember) {
|
||||
const match = branch.find(candidate => pathsEqual(candidate.path, discriminant.path));
|
||||
if (match === undefined) continue;
|
||||
declared++;
|
||||
if (!values.some(candidate => Object.is(candidate, match.value))) values.push(match.value);
|
||||
}
|
||||
if (values.length > 1) candidates.push({ path: discriminant.path, distinct: values.length, declared });
|
||||
}
|
||||
}
|
||||
return match;
|
||||
candidates.sort((left, right) => right.distinct - left.distinct || right.declared - left.declared);
|
||||
for (const candidate of candidates) {
|
||||
const actual = valueAtPath(value, candidate.path);
|
||||
const exact: IR[] = [];
|
||||
const defaults: IR[] = [];
|
||||
const expectedMembers: IR[] = [];
|
||||
for (let index = 0; index < members.length; index++) {
|
||||
const discriminant = byMember[index].find(item => pathsEqual(item.path, candidate.path));
|
||||
if (discriminant === undefined) {
|
||||
defaults.push(members[index]);
|
||||
continue;
|
||||
}
|
||||
expectedMembers.push({ k: "lit", v: discriminant.value });
|
||||
if (actual.present && Object.is(actual.value, discriminant.value)) exact.push(members[index]);
|
||||
}
|
||||
if (!actual.present) {
|
||||
if (defaults.length !== 0 && defaults.length < members.length) {
|
||||
return discriminateFailure(defaults, value, path);
|
||||
}
|
||||
return fail([...path, ...candidate.path], expectedOf({ k: "union", members: expectedMembers }), undefined);
|
||||
}
|
||||
if (exact.length === 0) {
|
||||
if (defaults.length !== 0) return discriminateFailure(defaults, value, path);
|
||||
return fail([...path, ...candidate.path], expectedOf({ k: "union", members: expectedMembers }), actual.value);
|
||||
}
|
||||
if (exact.length === 1) {
|
||||
const result = visit(exact[0], value, path);
|
||||
return result instanceof OmpErrors ? result : undefined;
|
||||
}
|
||||
const nested = discriminateFailure(exact, value, path);
|
||||
if (nested !== undefined) return nested;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** True when a value's runtime shape could only be aimed at this member. */
|
||||
|
||||
+980
-215
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ import type { IR, PropIR } from "./ir";
|
||||
export interface JsonSchemaOptions {
|
||||
description?: string;
|
||||
target?: string;
|
||||
dialect?: string;
|
||||
dialect?: string | null;
|
||||
/**
|
||||
* Which side of morphs and defaults to describe:
|
||||
* - `'input'` — accepted payloads: morphs emit their input shape, defaulted
|
||||
@@ -32,7 +32,7 @@ export function irToJsonSchema(ir: IR, options?: JsonSchemaOptions): JsonSchema
|
||||
let schema = emit(ir, ctx);
|
||||
if (ctx.defs.size > 0) schema.$defs = Object.fromEntries(ctx.defs);
|
||||
if (options?.target === "draft-07") schema = toDraft7(schema);
|
||||
const dialect = options?.dialect ?? dialectFor(options?.target);
|
||||
const dialect = options?.dialect === null ? undefined : (options?.dialect ?? dialectFor(options?.target));
|
||||
if (dialect !== undefined) schema.$schema = dialect;
|
||||
if (options?.description !== undefined) schema.description = options.description;
|
||||
return schema;
|
||||
@@ -41,7 +41,7 @@ export function irToJsonSchema(ir: IR, options?: JsonSchemaOptions): JsonSchema
|
||||
function dialectFor(target: string | undefined): string | undefined {
|
||||
if (target === "draft-2020-12") return "https://json-schema.org/draft/2020-12/schema";
|
||||
if (target === "draft-07") return "http://json-schema.org/draft-07/schema#";
|
||||
return target?.startsWith("http://") || target?.startsWith("https://") ? target : undefined;
|
||||
return target !== undefined && (target.startsWith("http://") || target.startsWith("https://")) ? target : undefined;
|
||||
}
|
||||
|
||||
function fallback(schema: JsonSchema, ctx: EmitCtx): JsonSchema {
|
||||
@@ -140,7 +140,6 @@ function emit(ir: IR, ctx: EmitCtx): JsonSchema {
|
||||
);
|
||||
schema = { type: "array", prefixItems, minItems: required };
|
||||
if (ir.variadic === undefined) {
|
||||
schema.maxItems = ir.prefix.length + ir.postfix.length;
|
||||
schema.items = false;
|
||||
} else {
|
||||
schema.items = emit(ir.variadic, ctx);
|
||||
@@ -185,10 +184,13 @@ function emit(ir: IR, ctx: EmitCtx): JsonSchema {
|
||||
} else {
|
||||
schema = ir.schema.hasSteps ? fallback(emit(ir.schema.ir, ctx), ctx) : emit(ir.schema.ir, ctx);
|
||||
}
|
||||
if (ir.schema.description !== undefined) schema.description = ir.schema.description;
|
||||
if (ir.schema.ir.desc !== undefined) schema.description = ir.schema.ir.desc;
|
||||
else if (ir.schema.description !== undefined && ir.descAuto !== true) {
|
||||
schema.description = ir.schema.description;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (ir.desc !== undefined) schema.description = ir.desc;
|
||||
if (ir.desc !== undefined && ir.descAuto !== true) schema.description = ir.desc;
|
||||
return schema;
|
||||
}
|
||||
|
||||
@@ -270,17 +272,16 @@ function emitObject(
|
||||
): JsonSchema {
|
||||
const properties: Record<string, unknown> = {};
|
||||
const required: string[] = [];
|
||||
// ArkType emits required properties first (each group in declaration
|
||||
// order); downstream wire consumers rely on that stable ordering.
|
||||
const filled = (prop: PropIR): boolean => !prop.opt && (ctx.options?.io === "output" || !prop.hasDefault);
|
||||
const ordered = [...props.filter(filled), ...props.filter(prop => !filled(prop))];
|
||||
for (const prop of ordered) {
|
||||
for (const prop of props) {
|
||||
if (typeof prop.key === "symbol") throw new TypeError("Cannot convert a symbol to a string");
|
||||
const key = String(prop.key);
|
||||
const propertySchema = emit(prop.val, ctx);
|
||||
if (prop.hasDefault) {
|
||||
propertySchema.default = prop.defFactory ? (prop.def as () => unknown)() : prop.def;
|
||||
}
|
||||
properties[prop.key] = propertySchema;
|
||||
if (filled(prop)) required.push(prop.key);
|
||||
properties[key] = propertySchema;
|
||||
if (filled(prop)) required.push(key);
|
||||
}
|
||||
const schema: JsonSchema = { type: "object", properties };
|
||||
if (required.length > 0) schema.required = required;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IR, MorphContext } from "./ir";
|
||||
|
||||
const NUMERIC = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
|
||||
const NUMERIC = /^(?:(?!^-0\.?0*$)(?:-?(?:(?:0|[1-9]\d*)(?:\.\d+)?)|\.\d+?))$/;
|
||||
const INTEGER = /^[+-]?(?:0|[1-9]\d*)$/;
|
||||
const EMAIL = /^[\w%+.-]+@[\d.A-Za-z-]+\.[A-Za-z]{2,}$/;
|
||||
const SEMVER =
|
||||
@@ -106,8 +106,118 @@ function isLuhnValid(input: string): boolean {
|
||||
return value.length > 0 && sum % 10 === 0;
|
||||
}
|
||||
|
||||
function finiteNumber(expected = "a number"): IR {
|
||||
return {
|
||||
k: "refine",
|
||||
base: { k: "unknown" },
|
||||
pred: value => typeof value === "number" && !Number.isNaN(value),
|
||||
expected,
|
||||
};
|
||||
}
|
||||
|
||||
function jsonObject(): IR {
|
||||
let value: IR;
|
||||
const resolveValue = (): IR => value;
|
||||
const object: IR = {
|
||||
k: "object",
|
||||
props: [],
|
||||
index: { k: "alias", name: "$jsonValue", resolve: resolveValue },
|
||||
extras: "keep",
|
||||
};
|
||||
const array: IR = {
|
||||
k: "array",
|
||||
el: { k: "alias", name: "$jsonValue", resolve: resolveValue },
|
||||
desc: "an object",
|
||||
};
|
||||
value = {
|
||||
k: "union",
|
||||
members: [
|
||||
object,
|
||||
array,
|
||||
{ k: "number" },
|
||||
{ k: "string" },
|
||||
{ k: "lit", v: false },
|
||||
{ k: "null" },
|
||||
{ k: "lit", v: true },
|
||||
],
|
||||
};
|
||||
return object;
|
||||
}
|
||||
|
||||
function instance(ctor: new (...args: never[]) => object, name: string): IR {
|
||||
return { k: "instance", ctor, expected: `${/^[AEIOU]/.test(name) ? "an" : "a"} ${name} instance` };
|
||||
}
|
||||
|
||||
const keywordFactories: Record<string, () => IR> = {
|
||||
string: () => ({ k: "string" }),
|
||||
Key: () => ({ k: "union", members: [{ k: "string" }, { k: "symbol" }] }),
|
||||
"unknown.any": () => ({ k: "unknown" }),
|
||||
Array: () => ({ k: "array", el: { k: "unknown" } }),
|
||||
Function: () => instance(Function as unknown as new (...args: never[]) => object, "Function"),
|
||||
RegExp: () => instance(RegExp, "RegExp"),
|
||||
File: () => instance(File, "File"),
|
||||
Error: () => instance(Error, "Error"),
|
||||
Set: () => instance(Set, "Set"),
|
||||
Map: () => instance(Map, "Map"),
|
||||
WeakSet: () => instance(WeakSet, "WeakSet"),
|
||||
WeakMap: () => instance(WeakMap, "WeakMap"),
|
||||
Promise: () => instance(Promise, "Promise"),
|
||||
FormData: () => instance(FormData, "FormData"),
|
||||
"FormData.parse": () => ({
|
||||
k: "morph",
|
||||
input: instance(FormData, "FormData"),
|
||||
fn: value => {
|
||||
const out: Record<string, Bun.FormDataEntryValue | Bun.FormDataEntryValue[]> = {};
|
||||
for (const [key, entry] of (value as FormData).entries()) {
|
||||
const current = out[key];
|
||||
out[key] = current === undefined ? entry : Array.isArray(current) ? [...current, entry] : [current, entry];
|
||||
}
|
||||
return out;
|
||||
},
|
||||
out: { k: "object", props: [], index: { k: "unknown" }, extras: "keep" },
|
||||
}),
|
||||
"object.json": jsonObject,
|
||||
"object.json.stringify": () => ({
|
||||
k: "morph",
|
||||
input: jsonObject(),
|
||||
fn: value => JSON.stringify(value),
|
||||
out: { k: "string" },
|
||||
}),
|
||||
"number.epoch": () => ({
|
||||
k: "refine",
|
||||
base: {
|
||||
k: "refine",
|
||||
base: {
|
||||
k: "refine",
|
||||
base: finiteNumber("a number representing a Unix timestamp"),
|
||||
pred: value => Number.isInteger(value),
|
||||
expected: "an integer representing a Unix timestamp",
|
||||
},
|
||||
pred: value => (value as number) >= -8_640_000_000_000_000,
|
||||
expected: "a Unix timestamp after -8640000000000000",
|
||||
},
|
||||
pred: value => (value as number) <= 8_640_000_000_000_000,
|
||||
expected: "a Unix timestamp before 8640000000000000",
|
||||
}),
|
||||
"number.safe": () => ({
|
||||
k: "refine",
|
||||
base: {
|
||||
k: "refine",
|
||||
base: finiteNumber(),
|
||||
pred: value => (value as number) >= Number.MIN_SAFE_INTEGER,
|
||||
expected: `at least ${Number.MIN_SAFE_INTEGER}`,
|
||||
},
|
||||
pred: value => (value as number) <= Number.MAX_SAFE_INTEGER,
|
||||
expected: `at most ${Number.MAX_SAFE_INTEGER}`,
|
||||
}),
|
||||
"number.NaN": () => ({
|
||||
k: "refine",
|
||||
base: { k: "unknown" },
|
||||
pred: Number.isNaN,
|
||||
expected: "NaN",
|
||||
}),
|
||||
"number.Infinity": () => ({ k: "lit", v: Number.POSITIVE_INFINITY }),
|
||||
"number.NegativeInfinity": () => ({ k: "lit", v: Number.NEGATIVE_INFINITY }),
|
||||
"string.alpha": () => pattern(/^[A-Za-z]*$/, "only letters"),
|
||||
"string.alphanumeric": () => pattern(/^[\dA-Za-z]*$/, "only letters and digits 0-9"),
|
||||
"string.hex": () => pattern(/^[\dA-Fa-f]+$/, "hex characters only"),
|
||||
|
||||
+2667
-253
File diff suppressed because it is too large
Load Diff
+136
-97
@@ -1,6 +1,6 @@
|
||||
import { OmpTypeError } from "./errors";
|
||||
import type { Def } from "./ir";
|
||||
import { type NarrowContext, type Type as OmpType, type } from "./type";
|
||||
import type { Def, EmbeddableSchema } from "./ir";
|
||||
import { type NarrowContext, type Type as OmpType, type ToJsonSchemaOptions, type } from "./type";
|
||||
|
||||
export interface Meta {
|
||||
title?: string;
|
||||
@@ -32,7 +32,7 @@ export interface ArrayOpts extends Meta {
|
||||
}
|
||||
|
||||
export interface ObjectOpts extends Meta {
|
||||
additionalProperties?: boolean | TSchema;
|
||||
additionalProperties?: boolean | AnySchema;
|
||||
}
|
||||
const OPTIONAL_INNER = Symbol("omptype.typebox.optionalInner");
|
||||
const OBJECT_INFO = Symbol("omptype.typebox.objectInfo");
|
||||
@@ -52,53 +52,84 @@ interface LegacyTypeBoxCompat<T> {
|
||||
safeParse(input: unknown): TypeBoxSafeParseResult<T>;
|
||||
}
|
||||
|
||||
export type TSchema<T = unknown> = OmpType<T> & LegacyTypeBoxCompat<T>;
|
||||
export type Static<T extends TSchema> = T["infer"];
|
||||
export type TAny = TSchema<unknown>;
|
||||
export type TUnknown = TSchema<unknown>;
|
||||
export type TNever = TSchema<never>;
|
||||
export type TNull = TSchema<null>;
|
||||
export type TString = TSchema<string>;
|
||||
export type TNumber = TSchema<number>;
|
||||
export type TInteger = TSchema<number>;
|
||||
export type TBoolean = TSchema<boolean>;
|
||||
export type TLiteral<V extends string | number | boolean | null> = TSchema<V>;
|
||||
export type TArray<E extends TSchema> = TSchema<Static<E>[]>;
|
||||
export type TTuple<E extends readonly TSchema[] = readonly TSchema[]> = TSchema<{
|
||||
/**
|
||||
* Erased schema surface accepted anywhere this facade takes a schema, native
|
||||
* omptype schemas included (those carry no legacy compat members).
|
||||
*
|
||||
* Members use method syntax so parameter positions stay bivariant: the typed
|
||||
* {@link TTyped} form is invariant in its static type (its `in`/`out`
|
||||
* validators are), so a concrete `TString` is not assignable to
|
||||
* `TTyped<unknown>`. Erasing here is what keeps `TString`, `TObject<…>` and
|
||||
* friends assignable to a plain schema annotation.
|
||||
*/
|
||||
export interface AnySchema extends EmbeddableSchema {
|
||||
(data: unknown): unknown;
|
||||
readonly infer: unknown;
|
||||
readonly hasSteps: boolean;
|
||||
toJsonSchema(options?: ToJsonSchemaOptions): Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every schema this facade returns: the TypeBox `TSchema` analog. Erased like
|
||||
* {@link AnySchema}, plus the legacy compat members builder results carry.
|
||||
*/
|
||||
export interface TSchema extends AnySchema {
|
||||
/** TypeBox compatibility validator used by legacy extension loaders. */
|
||||
__validator(data: unknown): unknown;
|
||||
/** Zod-style compatibility parser used by legacy extensions. */
|
||||
safeParse(input: unknown): TypeBoxSafeParseResult<unknown>;
|
||||
}
|
||||
|
||||
/** Schema carrying a statically known type; every `TXxx` alias resolves here. */
|
||||
export type TTyped<T> = OmpType<T> & LegacyTypeBoxCompat<T>;
|
||||
export type Static<T extends AnySchema> = T["infer"];
|
||||
export type TAny = TTyped<unknown>;
|
||||
export type TUnknown = TTyped<unknown>;
|
||||
export type TNever = TTyped<never>;
|
||||
export type TNull = TTyped<null>;
|
||||
export type TString = TTyped<string>;
|
||||
export type TNumber = TTyped<number>;
|
||||
export type TInteger = TTyped<number>;
|
||||
export type TBoolean = TTyped<boolean>;
|
||||
export type TLiteral<V extends string | number | boolean | null> = TTyped<V>;
|
||||
export type TArray<E extends AnySchema> = TTyped<Static<E>[]>;
|
||||
export type TTuple<E extends readonly AnySchema[] = readonly AnySchema[]> = TTyped<{
|
||||
-readonly [K in keyof E]: Static<E[K]>;
|
||||
}>;
|
||||
export type TOptional<E extends TSchema> = TSchema<Static<E> | undefined> & { readonly [OPTIONAL_INNER]: E };
|
||||
export type TUnion<E extends readonly TSchema[] = readonly TSchema[]> = TSchema<Static<E[number]>>;
|
||||
export type TIntersect<E extends readonly TSchema[] = readonly TSchema[]> = TSchema<
|
||||
export type TOptional<E extends AnySchema> = TTyped<Static<E> | undefined> & { readonly [OPTIONAL_INNER]: E };
|
||||
export type TUnion<E extends readonly AnySchema[] = readonly AnySchema[]> = TTyped<Static<E[number]>>;
|
||||
export type TIntersect<E extends readonly AnySchema[] = readonly AnySchema[]> = TTyped<
|
||||
UnionToIntersection<Static<E[number]>>
|
||||
>;
|
||||
export type TEnum<E extends readonly (string | number)[] = readonly (string | number)[]> = TSchema<E[number]>;
|
||||
export type TRecord<K extends TSchema, V extends TSchema> = TSchema<Record<Extract<Static<K>, PropertyKey>, Static<V>>>;
|
||||
export type TNullable<E extends TSchema> = TSchema<Static<E> | null>;
|
||||
export type TReadonly<E extends TSchema> = TSchema<Readonly<Static<E>>>;
|
||||
export type TUnsafe<T = unknown> = TSchema<T>;
|
||||
export type TEnum<E extends readonly (string | number)[] = readonly (string | number)[]> = TTyped<E[number]>;
|
||||
export type TRecord<K extends AnySchema, V extends AnySchema> = TTyped<
|
||||
Record<Extract<Static<K>, PropertyKey>, Static<V>>
|
||||
>;
|
||||
export type TNullable<E extends AnySchema> = TTyped<Static<E> | null>;
|
||||
export type TReadonly<E extends AnySchema> = TTyped<Readonly<Static<E>>>;
|
||||
export type TUnsafe<T = unknown> = TTyped<T>;
|
||||
|
||||
type OptionalKeys<P extends Record<string, TSchema>> = {
|
||||
[K in keyof P]-?: P[K] extends { readonly [OPTIONAL_INNER]: TSchema } ? K : never;
|
||||
type OptionalKeys<P extends Record<string, AnySchema>> = {
|
||||
[K in keyof P]-?: P[K] extends { readonly [OPTIONAL_INNER]: AnySchema } ? K : never;
|
||||
}[keyof P];
|
||||
type RequiredKeys<P extends Record<string, TSchema>> = Exclude<keyof P, OptionalKeys<P>>;
|
||||
type ObjectStatic<P extends Record<string, TSchema>> = {
|
||||
type RequiredKeys<P extends Record<string, AnySchema>> = Exclude<keyof P, OptionalKeys<P>>;
|
||||
type ObjectStatic<P extends Record<string, AnySchema>> = {
|
||||
[K in RequiredKeys<P>]: Static<P[K]>;
|
||||
} & {
|
||||
[K in OptionalKeys<P>]?: Exclude<Static<P[K]>, undefined>;
|
||||
};
|
||||
export type TObject<P extends Record<string, TSchema> = Record<string, TSchema>> = TSchema<ObjectStatic<P>>;
|
||||
type RequiredProps<P extends Record<string, TSchema>> = {
|
||||
export type TObject<P extends Record<string, AnySchema> = Record<string, AnySchema>> = TTyped<ObjectStatic<P>>;
|
||||
type RequiredProps<P extends Record<string, AnySchema>> = {
|
||||
[K in keyof P]: P[K] extends TOptional<infer E> ? E : P[K];
|
||||
};
|
||||
|
||||
interface RuntimeType<T> extends OmpType<T> {
|
||||
[OPTIONAL_INNER]?: TSchema;
|
||||
[OPTIONAL_INNER]?: AnySchema;
|
||||
[OBJECT_INFO]?: ObjectInfo;
|
||||
describe(description: string): RuntimeType<T>;
|
||||
default(value: T | (() => T)): RuntimeType<T>;
|
||||
or<R>(schema: OmpType<R>): RuntimeType<T | R>;
|
||||
and<R>(schema: OmpType<R>): RuntimeType<T & R>;
|
||||
or<schema extends AnySchema>(schema: schema): RuntimeType<T | Static<schema>>;
|
||||
and<schema extends AnySchema>(schema: schema): RuntimeType<T & Static<schema>>;
|
||||
array(): RuntimeType<T[]>;
|
||||
atLeastLength(bound: number): RuntimeType<T>;
|
||||
atMostLength(bound: number): RuntimeType<T>;
|
||||
@@ -109,7 +140,18 @@ interface RuntimeType<T> extends OmpType<T> {
|
||||
}
|
||||
type CompatRuntime<T> = RuntimeType<T> & LegacyTypeBoxCompat<T>;
|
||||
|
||||
type ObjectInfo = { props: Record<string, TSchema>; additionalProperties?: boolean | TSchema };
|
||||
type ObjectInfo = {
|
||||
props: Record<string, AnySchema>;
|
||||
additionalProperties?: boolean | AnySchema;
|
||||
};
|
||||
|
||||
function asRuntime<T>(schema: AnySchema): RuntimeType<T> {
|
||||
return schema as unknown as RuntimeType<T>;
|
||||
}
|
||||
|
||||
function asSchema<T>(schema: AnySchema): TTyped<T> {
|
||||
return schema as unknown as TTyped<T>;
|
||||
}
|
||||
|
||||
function validationFailure(message: string): TypeBoxValidationFailure {
|
||||
return { message };
|
||||
@@ -161,9 +203,7 @@ function checkFiniteOption(name: string, value: number | undefined): void {
|
||||
function tString(opts?: StringOpts): TString {
|
||||
checkFiniteOption("minLength", opts?.minLength);
|
||||
checkFiniteOption("maxLength", opts?.maxLength);
|
||||
let schema = type.raw(
|
||||
opts?.format === "url" || opts?.format === "uri" ? "string.url" : "string",
|
||||
) as RuntimeType<string>;
|
||||
let schema = asRuntime<string>(type.raw(opts?.format === "url" || opts?.format === "uri" ? "string.url" : "string"));
|
||||
if (opts?.minLength !== undefined) schema = schema.atLeastLength(opts.minLength);
|
||||
if (opts?.maxLength !== undefined) schema = schema.atMostLength(opts.maxLength);
|
||||
if (opts?.pattern !== undefined) {
|
||||
@@ -229,7 +269,7 @@ function tNumber(opts?: NumberOpts, integer = false): TNumber {
|
||||
const keyword = integer ? "number.integer" : "number";
|
||||
const lowerDsl = lower ? `${lower.value} ${lower.exclusive ? "<" : "<="} ` : "";
|
||||
const upperDsl = upper ? ` ${upper.exclusive ? "<" : "<="} ${upper.value}` : "";
|
||||
let schema = type.raw(`${lowerDsl}${keyword}${upperDsl}`) as RuntimeType<number>;
|
||||
let schema = asRuntime<number>(type.raw(`${lowerDsl}${keyword}${upperDsl}`));
|
||||
if (opts?.multipleOf !== undefined) {
|
||||
const divisor = opts.multipleOf;
|
||||
schema = schema.narrow((value, ctx) => {
|
||||
@@ -244,33 +284,32 @@ function tNumber(opts?: NumberOpts, integer = false): TNumber {
|
||||
}
|
||||
|
||||
function tLiteral<const V extends string | number | boolean | null>(value: V, opts?: Meta): TLiteral<V> {
|
||||
return applyMeta(type.enumerated(value) as RuntimeType<V>, opts);
|
||||
return applyMeta(asRuntime<V>(type.enumerated(value)), opts);
|
||||
}
|
||||
|
||||
function tNever(opts?: Meta): TNever {
|
||||
return applyMeta(
|
||||
(type.raw("unknown") as RuntimeType<unknown>).narrow((_value, ctx): _value is never => ctx.mustBe("never")),
|
||||
asRuntime<unknown>(type.raw("unknown")).narrow((_value, ctx): _value is never => ctx.mustBe("never")),
|
||||
opts,
|
||||
);
|
||||
}
|
||||
|
||||
function tUnion<const E extends readonly TSchema[]>(schemas: E, opts?: Meta): TUnion<E> {
|
||||
if (schemas.length === 0) return tNever(opts) as TUnion<E>;
|
||||
let result = schemas[0] as unknown as RuntimeType<unknown>;
|
||||
function tUnion<const E extends readonly AnySchema[]>(schemas: E, opts?: Meta): TUnion<E> {
|
||||
if (schemas.length === 0) return asSchema<Static<E[number]>>(tNever(opts));
|
||||
let result = asRuntime<unknown>(schemas[0]);
|
||||
for (let i = 1; i < schemas.length; i++) result = result.or(schemas[i]);
|
||||
return applyMeta(result, opts) as TUnion<E>;
|
||||
return asSchema<Static<E[number]>>(applyMeta(result, opts));
|
||||
}
|
||||
|
||||
function tIntersect<const E extends readonly TSchema[]>(
|
||||
function tIntersect<const E extends readonly AnySchema[]>(
|
||||
schemas: E,
|
||||
opts?: Meta,
|
||||
): TSchema<UnionToIntersection<Static<E[number]>>> {
|
||||
if (schemas.length === 0)
|
||||
return applyMeta(type.raw("unknown") as RuntimeType<unknown>, opts) as TSchema<
|
||||
UnionToIntersection<Static<E[number]>>
|
||||
>;
|
||||
): TTyped<UnionToIntersection<Static<E[number]>>> {
|
||||
if (schemas.length === 0) {
|
||||
return applyMeta(asRuntime<UnionToIntersection<Static<E[number]>>>(type.raw("unknown")), opts);
|
||||
}
|
||||
const validateAll = (): RuntimeType<UnionToIntersection<Static<E[number]>>> => {
|
||||
const base = type.raw("unknown") as RuntimeType<unknown>;
|
||||
const base = asRuntime<unknown>(type.raw("unknown"));
|
||||
return base.narrow((value, ctx): value is UnionToIntersection<Static<E[number]>> => {
|
||||
for (const schema of schemas) {
|
||||
if (schema(value) instanceof type.errors) return ctx.mustBe("a value satisfying every intersection member");
|
||||
@@ -279,14 +318,14 @@ function tIntersect<const E extends readonly TSchema[]>(
|
||||
});
|
||||
};
|
||||
if (schemas.some(schema => schema.hasSteps)) return applyMeta(validateAll(), opts);
|
||||
let result = schemas[0] as unknown as RuntimeType<unknown>;
|
||||
let result = asRuntime<unknown>(schemas[0]);
|
||||
try {
|
||||
for (let i = 1; i < schemas.length; i++) result = result.and(schemas[i]);
|
||||
} catch (error) {
|
||||
if (error instanceof OmpTypeError) return applyMeta(validateAll(), opts);
|
||||
throw error;
|
||||
}
|
||||
return applyMeta(result, opts) as TSchema<UnionToIntersection<Static<E[number]>>>;
|
||||
return asSchema<UnionToIntersection<Static<E[number]>>>(applyMeta(result, opts));
|
||||
}
|
||||
type UnionToIntersection<U> = (U extends unknown ? (value: U) => void : never) extends (value: infer I) => void
|
||||
? I
|
||||
@@ -306,16 +345,17 @@ function enumValues(values: Record<string, string | number> | readonly (string |
|
||||
function tEnum<const E extends Record<string, string | number> | readonly (string | number)[]>(
|
||||
values: E,
|
||||
opts?: Meta,
|
||||
): TSchema<E extends readonly (infer V)[] ? V : E[keyof E]> {
|
||||
return applyMeta(type.enumerated(...enumValues(values)) as RuntimeType<string | number>, opts) as unknown as TSchema<
|
||||
E extends readonly (infer V)[] ? V : E[keyof E]
|
||||
>;
|
||||
): TTyped<E extends readonly (infer V)[] ? V : E[keyof E]> {
|
||||
return applyMeta(
|
||||
asRuntime<E extends readonly (infer V)[] ? V : E[keyof E]>(type.enumerated(...enumValues(values))),
|
||||
opts,
|
||||
);
|
||||
}
|
||||
|
||||
function tArray<E extends TSchema>(item: E, opts?: ArrayOpts): TArray<E> {
|
||||
function tArray<E extends AnySchema>(item: E, opts?: ArrayOpts): TArray<E> {
|
||||
checkFiniteOption("minItems", opts?.minItems);
|
||||
checkFiniteOption("maxItems", opts?.maxItems);
|
||||
let schema = (item as unknown as RuntimeType<Static<E>>).array();
|
||||
let schema = asRuntime<Static<E>>(item).array();
|
||||
if (opts?.minItems !== undefined) schema = schema.atLeastLength(opts.minItems);
|
||||
if (opts?.maxItems !== undefined) schema = schema.atMostLength(opts.maxItems);
|
||||
if (opts?.uniqueItems) {
|
||||
@@ -342,8 +382,8 @@ function jsonEqual(left: unknown, right: unknown): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function tTuple<const E extends readonly TSchema[]>(items: E, opts?: Meta): TTuple<E> {
|
||||
const schema = (type.raw("unknown") as RuntimeType<unknown>).narrow(
|
||||
function tTuple<const E extends readonly AnySchema[]>(items: E, opts?: Meta): TTuple<E> {
|
||||
const schema = asRuntime<unknown>(type.raw("unknown")).narrow(
|
||||
(value, ctx): value is { -readonly [K in keyof E]: Static<E[K]> } => {
|
||||
if (!Array.isArray(value) || value.length !== items.length)
|
||||
return ctx.mustBe(`a tuple of length ${items.length}`);
|
||||
@@ -355,25 +395,25 @@ function tTuple<const E extends readonly TSchema[]>(items: E, opts?: Meta): TTup
|
||||
return applyMeta(schema, opts);
|
||||
}
|
||||
|
||||
function tObject<const P extends Record<string, TSchema>>(properties: P, opts?: ObjectOpts): TObject<P> {
|
||||
function tObject<const P extends Record<string, AnySchema>>(properties: P, opts?: ObjectOpts): TObject<P> {
|
||||
const def: Record<string, Def> = {};
|
||||
const props: Record<string, TSchema> = {};
|
||||
const props: Record<string, AnySchema> = {};
|
||||
for (const key in properties) {
|
||||
const schema = properties[key];
|
||||
const inner = (schema as unknown as RuntimeType<unknown>)[OPTIONAL_INNER];
|
||||
def[inner ? `${key}?` : key] = (inner ?? schema) as Def;
|
||||
const inner = asRuntime<unknown>(schema)[OPTIONAL_INNER];
|
||||
def[inner ? `${key}?` : key] = inner ?? schema;
|
||||
props[key] = schema;
|
||||
}
|
||||
if (opts?.additionalProperties === false) def["+"] = "reject";
|
||||
else if (opts?.additionalProperties && opts.additionalProperties !== true)
|
||||
def["[string]"] = opts.additionalProperties as Def;
|
||||
const schema = applyMeta(type.raw(def) as RuntimeType<ObjectStatic<P>>, opts);
|
||||
def["[string]"] = opts.additionalProperties;
|
||||
const schema = applyMeta(asRuntime<ObjectStatic<P>>(type.raw(def)), opts);
|
||||
schema[OBJECT_INFO] = { props, additionalProperties: opts?.additionalProperties };
|
||||
return schema;
|
||||
}
|
||||
|
||||
function tRecord<K extends TSchema, V extends TSchema>(key: K, value: V, opts?: Meta): TRecord<K, V> {
|
||||
const base = (type.raw({ "[string]": value }) as RuntimeType<Record<string, Static<V>>>).narrow((record, ctx) => {
|
||||
function tRecord<K extends AnySchema, V extends AnySchema>(key: K, value: V, opts?: Meta): TRecord<K, V> {
|
||||
const base = asRuntime<Record<string, Static<V>>>(type.raw({ "[string]": value })).narrow((record, ctx) => {
|
||||
for (const name in record)
|
||||
if (key(name) instanceof type.errors) return ctx.mustBe("an object with valid record keys");
|
||||
return true;
|
||||
@@ -381,72 +421,70 @@ function tRecord<K extends TSchema, V extends TSchema>(key: K, value: V, opts?:
|
||||
return applyMeta(base, opts) as TRecord<K, V>;
|
||||
}
|
||||
|
||||
function tOptional<E extends TSchema>(schema: E, opts?: Meta): TOptional<E> {
|
||||
function tOptional<E extends AnySchema>(schema: E, opts?: Meta): TOptional<E> {
|
||||
const marker = applyMeta(
|
||||
(schema as unknown as RuntimeType<Static<E>>).or(type.raw("undefined")),
|
||||
asRuntime<Static<E>>(schema).or(asRuntime<undefined>(type.raw("undefined"))),
|
||||
opts,
|
||||
) as RuntimeType<Static<E> | undefined>;
|
||||
marker[OPTIONAL_INNER] = schema;
|
||||
return marker as unknown as TOptional<E>;
|
||||
}
|
||||
|
||||
function tNullable<E extends TSchema>(schema: E, opts?: Meta): TSchema<Static<E> | null> {
|
||||
return applyMeta((schema as unknown as RuntimeType<Static<E>>).or(type.raw("null")), opts);
|
||||
function tNullable<E extends AnySchema>(schema: E, opts?: Meta): TTyped<Static<E> | null> {
|
||||
return applyMeta(asRuntime<Static<E>>(schema).or(asRuntime<null>(type.raw("null"))), opts);
|
||||
}
|
||||
|
||||
function requireObject(schema: TSchema, operation: string): ObjectInfo {
|
||||
const info = (schema as unknown as RuntimeType<unknown>)[OBJECT_INFO];
|
||||
function requireObject(schema: AnySchema, operation: string): ObjectInfo {
|
||||
const info = asRuntime<unknown>(schema)[OBJECT_INFO];
|
||||
if (!info) throw new OmpTypeError(`Type.${operation} requires a schema created by Type.Object`);
|
||||
return info;
|
||||
}
|
||||
|
||||
function tPartial<P extends Record<string, TSchema>>(schema: TObject<P>): TSchema<Partial<ObjectStatic<P>>> {
|
||||
function tPartial<P extends Record<string, AnySchema>>(schema: TObject<P>): TTyped<Partial<ObjectStatic<P>>> {
|
||||
const info = requireObject(schema, "Partial");
|
||||
const props: Record<string, TSchema> = {};
|
||||
const props: Record<string, AnySchema> = {};
|
||||
for (const key in info.props)
|
||||
props[key] = (info.props[key] as unknown as RuntimeType<unknown>)[OPTIONAL_INNER]
|
||||
? info.props[key]
|
||||
: tOptional(info.props[key]);
|
||||
return tObject(props, { additionalProperties: info.additionalProperties }) as TSchema<Partial<ObjectStatic<P>>>;
|
||||
props[key] = asRuntime<unknown>(info.props[key])[OPTIONAL_INNER] ? info.props[key] : tOptional(info.props[key]);
|
||||
return tObject(props, { additionalProperties: info.additionalProperties }) as TTyped<Partial<ObjectStatic<P>>>;
|
||||
}
|
||||
|
||||
function tRequired<P extends Record<string, TSchema>>(schema: TObject<P>): TObject<RequiredProps<P>> {
|
||||
function tRequired<P extends Record<string, AnySchema>>(schema: TObject<P>): TObject<RequiredProps<P>> {
|
||||
const info = requireObject(schema, "Required");
|
||||
const props: Record<string, TSchema> = {};
|
||||
const props: Record<string, AnySchema> = {};
|
||||
for (const key in info.props) {
|
||||
props[key] = (info.props[key] as unknown as RuntimeType<unknown>)[OPTIONAL_INNER] ?? info.props[key];
|
||||
props[key] = asRuntime<unknown>(info.props[key])[OPTIONAL_INNER] ?? info.props[key];
|
||||
}
|
||||
return tObject(props, { additionalProperties: info.additionalProperties }) as TObject<RequiredProps<P>>;
|
||||
}
|
||||
|
||||
function tPick<P extends Record<string, TSchema>, const K extends readonly (keyof P)[]>(
|
||||
function tPick<P extends Record<string, AnySchema>, const K extends readonly (keyof P)[]>(
|
||||
schema: TObject<P>,
|
||||
keys: K,
|
||||
): TObject<Pick<P, K[number]>> {
|
||||
const info = requireObject(schema, "Pick");
|
||||
const props: Record<string, TSchema> = {};
|
||||
const props: Record<string, AnySchema> = {};
|
||||
for (const key of keys) if (typeof key === "string" && info.props[key]) props[key] = info.props[key];
|
||||
return tObject(props, { additionalProperties: info.additionalProperties }) as TObject<Pick<P, K[number]>>;
|
||||
}
|
||||
|
||||
function tOmit<P extends Record<string, TSchema>, const K extends readonly (keyof P)[]>(
|
||||
function tOmit<P extends Record<string, AnySchema>, const K extends readonly (keyof P)[]>(
|
||||
schema: TObject<P>,
|
||||
keys: K,
|
||||
): TObject<Omit<P, K[number]>> {
|
||||
const info = requireObject(schema, "Omit");
|
||||
const omitted = new Set<PropertyKey>(keys);
|
||||
const props: Record<string, TSchema> = {};
|
||||
const props: Record<string, AnySchema> = {};
|
||||
for (const key in info.props) if (!omitted.has(key)) props[key] = info.props[key];
|
||||
return tObject(props, { additionalProperties: info.additionalProperties }) as TObject<Omit<P, K[number]>>;
|
||||
}
|
||||
|
||||
function tComposite<const E extends readonly TObject<Record<string, TSchema>>[]>(
|
||||
function tComposite<const E extends readonly TObject<Record<string, AnySchema>>[]>(
|
||||
schemas: E,
|
||||
opts?: ObjectOpts,
|
||||
): TSchema<UnionToIntersection<Static<E[number]>>> {
|
||||
const props: Record<string, TSchema> = {};
|
||||
): TTyped<UnionToIntersection<Static<E[number]>>> {
|
||||
const props: Record<string, AnySchema> = {};
|
||||
for (const schema of schemas) Object.assign(props, requireObject(schema, "Composite").props);
|
||||
return tObject(props, opts) as TSchema<UnionToIntersection<Static<E[number]>>>;
|
||||
return asSchema<UnionToIntersection<Static<E[number]>>>(tObject(props, opts));
|
||||
}
|
||||
|
||||
function tUnsafe<T = unknown>(_jsonSchema: Record<string, unknown> = {}): TUnsafe<T> {
|
||||
@@ -459,10 +497,10 @@ export const Type = {
|
||||
String: tString,
|
||||
Number: (opts?: NumberOpts) => tNumber(opts),
|
||||
Integer: (opts?: NumberOpts) => tNumber(opts, true),
|
||||
Boolean: (opts?: Meta) => applyMeta(type.raw("boolean") as RuntimeType<boolean>, opts),
|
||||
Null: (opts?: Meta) => applyMeta(type.raw("null") as RuntimeType<null>, opts),
|
||||
Any: (opts?: Meta) => applyMeta(type.raw("unknown") as RuntimeType<unknown>, opts),
|
||||
Unknown: (opts?: Meta) => applyMeta(type.raw("unknown") as RuntimeType<unknown>, opts),
|
||||
Boolean: (opts?: Meta) => applyMeta(asRuntime<boolean>(type.raw("boolean")), opts),
|
||||
Null: (opts?: Meta) => applyMeta(asRuntime<null>(type.raw("null")), opts),
|
||||
Any: (opts?: Meta) => applyMeta(asRuntime<unknown>(type.raw("unknown")), opts),
|
||||
Unknown: (opts?: Meta) => applyMeta(asRuntime<unknown>(type.raw("unknown")), opts),
|
||||
Never: tNever,
|
||||
Literal: tLiteral,
|
||||
Union: tUnion,
|
||||
@@ -474,7 +512,8 @@ export const Type = {
|
||||
Record: tRecord,
|
||||
Optional: tOptional,
|
||||
Nullable: tNullable,
|
||||
Readonly: <E extends TSchema>(schema: E): E => withLegacyCompat(schema) as unknown as E,
|
||||
Readonly: <E extends AnySchema>(schema: E): TReadonly<E> =>
|
||||
asSchema<Readonly<Static<E>>>(withLegacyCompat(asRuntime<Readonly<Static<E>>>(schema))),
|
||||
Partial: tPartial,
|
||||
Required: tRequired,
|
||||
Pick: tPick,
|
||||
|
||||
@@ -71,7 +71,7 @@ function restrictBase<Out>(source: Decoratable<Out>, ir: IR): Decoratable<Out> {
|
||||
let next = source.hasSteps
|
||||
? schemaFromIR<Out>({ k: "morph", input: ir, fn: value => source(value) })
|
||||
: schemaFromIR<Out>(ir);
|
||||
if (source.description !== undefined) next = next.describe(source.description);
|
||||
if (source.ir.desc !== undefined) next = next.describe(source.ir.desc);
|
||||
if (source.hasDefault) next = next.default(source.defaultValue as Out | (() => Out));
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -475,4 +475,19 @@ describe("Standard Schema V1", () => {
|
||||
expect(failed.issues[0].path).toEqual(["name"]);
|
||||
expect(failed.issues[0].message).toContain("a string");
|
||||
});
|
||||
|
||||
it("materializes root defaults for undefined input at the standard boundary", () => {
|
||||
const staticDefault = type.string.default("dev");
|
||||
expect(staticDefault["~standard"].validate(undefined)).toEqual({ value: "dev" });
|
||||
expect(staticDefault(undefined)).toBe("dev");
|
||||
expect(staticDefault("prod")).toBe("prod");
|
||||
expect(staticDefault(5)).toBeInstanceOf(OmpErrors);
|
||||
|
||||
// Factory defaults run per call — distinct instances each time.
|
||||
const factoryDefault = type("string[]").default(() => []);
|
||||
const first = factoryDefault(undefined);
|
||||
const second = factoryDefault(undefined);
|
||||
expect(first).toEqual([]);
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user