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:
can1357
2026-08-04 03:23:38 +02:00
parent f7b9170f2d
commit 90b5d8e707
12 changed files with 5341 additions and 1035 deletions
+15
View File
@@ -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
File diff suppressed because it is too large Load Diff
+247 -49
View File
@@ -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);
}
+67 -12
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+13 -12
View File
@@ -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;
+111 -1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+136 -97
View File
@@ -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,
+1 -1
View File
@@ -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;
}
+15
View File
@@ -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);
});
});