diff --git a/packages/omptype/CHANGELOG.md b/packages/omptype/CHANGELOG.md index f7d057c39..ab675e6a8 100644 --- a/packages/omptype/CHANGELOG.md +++ b/packages/omptype/CHANGELOG.md @@ -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 diff --git a/packages/omptype/src/compile.ts b/packages/omptype/src/compile.ts index 55972ca95..2e6f34e1c 100644 --- a/packages/omptype/src/compile.ts +++ b/packages/omptype/src/compile.ts @@ -15,17 +15,12 @@ * members compile to inline predicates */ import { MISSING, OmpErrors } from "./errors"; -import { canRefineUnionFailure, unionFail, walk } from "./interp"; +import { canRefineUnionFailure, materializeDefault, unionFail, walk } from "./interp"; import { expectedOf, hasMorph, type IR, type MorphContext, type PropIR, type TupleIR } from "./ir"; const own = Object.prototype.hasOwnProperty; - const IDENT = /^[A-Za-z_$][\w$]*$/; -function access(base: string, key: string): string { - return IDENT.test(key) ? `${base}.${key}` : `${base}[${JSON.stringify(key)}]`; -} - /** Inline-able literal, else undefined (caller hoists into the refs pool). */ function litSource(v: unknown): string | undefined { if (v === null) return "null"; @@ -115,8 +110,12 @@ class Builder { return litSource(v) ?? this.ref(v); } + access(base: string, key: PropertyKey): string { + return typeof key === "string" && IDENT.test(key) ? `${base}.${key}` : `${base}[${this.lit(key)}]`; + } + pathExpr(segs: PathSeg[]): string { - const parts = segs.map(seg => ("s" in seg ? JSON.stringify(seg.s) : seg.d)); + const parts = segs.map(seg => ("s" in seg ? this.lit(seg.s) : seg.d)); return `[${parts.join(",")}]`; } @@ -124,7 +123,7 @@ class Builder { if (segs.length === 0) return "undefined"; if (segs.length === 1) { const seg = segs[0]; - return "s" in seg ? JSON.stringify(seg.s) : seg.d; + return "s" in seg ? this.lit(seg.s) : seg.d; } const staticParts: PropertyKey[] = []; for (const seg of segs) { @@ -134,8 +133,16 @@ class Builder { return this.ref(staticParts); } + error(segs: PathSeg[], expected: string, dataExpr: string): string { + return `new AE(${this.storedPathExpr(segs)},${JSON.stringify(expected)},${dataExpr})`; + } + fail(segs: PathSeg[], expected: string, dataExpr: string): string { - return `return new AE(${this.storedPathExpr(segs)},${JSON.stringify(expected)},${dataExpr})`; + return `return ${this.error(segs, expected, dataExpr)}`; + } + + appendError(errors: string, error: string): string { + return `if(${errors}===undefined)${errors}=${error};else ${errors}.append(${error});`; } /** Pure boolean predicate for a morph-free subtree. */ @@ -202,10 +209,10 @@ class Builder { return out; } case "object": { - const checks = [`typeof ${v}==="object"`, `${v}!==null`, `!Array.isArray(${v})`]; + const checks = [`typeof ${v}==="object"`, `${v}!==null`]; for (const p of node.props) { - const av = access(v, p.key); - const present = `${JSON.stringify(p.key)} in ${v}`; + const av = this.access(v, p.key); + const present = `${this.lit(p.key)} in ${v}`; const predicate = this.predicate(p.val, av); checks.push( p.opt || p.hasDefault @@ -217,17 +224,41 @@ class Builder { : `((${present})&&(${predicate}))`, ); } - if (node.index) { - const k = this.next("k"); + const stringKey = this.next("k"); + if (node.index !== undefined) { checks.push( - `(()=>{for(const ${k} in ${v})if(own.call(${v},${k})&&!(${this.predicate(node.index, `${v}[${k}]`)}))return false;return true})()`, + `(()=>{for(const ${stringKey} in ${v})if(own.call(${v},${stringKey})&&!(${this.predicate(node.index, `${v}[${stringKey}]`)}))return false;return true})()`, ); - } else if (node.extras === "reject") { - const k = this.next("k"); + } + if (node.patternIndexes !== undefined) { + for (const pattern of node.patternIndexes) { + checks.push( + `(()=>{for(const ${stringKey} in ${v})if(own.call(${v},${stringKey})&&(${this.predicate(pattern.key, stringKey)})&&!(${this.predicate(pattern.val, `${v}[${stringKey}]`)}))return false;return true})()`, + ); + } + } + if (node.symbolIndex !== undefined) { + const symbol = this.next("s"); checks.push( - `(()=>{for(const ${k} in ${v})if(own.call(${v},${k})&&!(${this.declaredCheck(node.props, k)}))return false;return true})()`, + `(()=>{for(const ${symbol} of Object.getOwnPropertySymbols(${v}))if(Object.prototype.propertyIsEnumerable.call(${v},${symbol})&&!(${this.predicate(node.symbolIndex, `${v}[${symbol}]`)}))return false;return true})()`, ); } + if (node.extras === "reject") { + const patternMatch = + node.patternIndexes?.map(pattern => `(${this.predicate(pattern.key, stringKey)})`).join("||") ?? + "false"; + if (node.index === undefined) { + checks.push( + `(()=>{for(const ${stringKey} in ${v})if(own.call(${v},${stringKey})&&!(${this.declaredCheck(node.props, stringKey)})&&!(${patternMatch}))return false;return true})()`, + ); + } + if (node.symbolIndex === undefined) { + const symbol = this.next("s"); + checks.push( + `(()=>{for(const ${symbol} of Object.getOwnPropertySymbols(${v}))if(Object.prototype.propertyIsEnumerable.call(${v},${symbol})&&!(${this.declaredCheck(node.props, symbol)}))return false;return true})()`, + ); + } + } return `(${checks.join("&&")})`; } case "sub": @@ -243,39 +274,341 @@ class Builder { const set = this.ref(new Set(props.map(p => p.key))); return `${set}.has(${keyVar})`; } - return `(${props.map(p => `${keyVar}===${JSON.stringify(p.key)}`).join("||")})`; + return `(${props.map(p => `${keyVar}===${this.lit(p.key)}`).join("||")})`; } - emitDelegate(node: IR, v: string, segs: PathSeg[], out?: string): void { - const runner = node.k === "sub" ? node.schema.run : boundWalk(node); + /** + * Run a node through its interpreter/sub-schema runner, appending any + * failure to `errors`. `brk` (when given) exits the enclosing block on + * failure so dependent statements (output assignment, morph fns) are + * skipped. Both runner kinds receive the absolute path so nested step + * callbacks observe ctx.path; walk-produced errors are already absolute, + * while sub runners return schema-relative errors that need prefixing. + */ + emitCollectDelegate(node: IR, v: string, segs: PathSeg[], errors: string, brk?: string, out?: string): void { + const sub = node.k === "sub"; + const runner = sub ? node.schema.run : boundWalk(node); const result = this.next("r"); - this.push(`const ${result}=${this.ref(runner)}(${v});`); + const args = segs.length > 0 ? `${v},${this.pathExpr(segs)}` : v; + this.push(`const ${result}=${this.ref(runner)}(${args});`); + const failure = sub && segs.length > 0 ? `PF(${result},${this.pathExpr(segs)})` : result; this.push( - segs.length === 0 - ? `if(${result} instanceof AE)return ${result};` - : `if(${result} instanceof AE)return PF(${result},${this.pathExpr(segs)});`, + `if(${result} instanceof AE){${this.appendError(errors, failure)}${brk === undefined ? "" : `break ${brk};`}}`, ); if (out !== undefined) this.push(`${out}=${result};`); } + /** Snapshot the error count so sequencing sites can detect soft failures. */ + markErrors(errors: string): string { + const mark = this.next("n"); + this.push(`const ${mark}=${errors}===void 0?0:${errors}.length;`); + return mark; + } + + /** Exit `brk` when errors were appended since `mark` (interp's return-on-error). */ + guardGrowth(errors: string, mark: string, brk: string): void { + this.push(`if((${errors}===void 0?0:${errors}.length)!==${mark})break ${brk};`); + } + + /** Aggregate every independent failure in a morph-free subtree. */ + emitCollectCheck(node: IR, v: string, segs: PathSeg[], errors: string, failureData = v): void { + if (node.cfg !== undefined || node.k === "refine") { + this.emitCollectDelegate(node, v, segs, errors); + return; + } + switch (node.k) { + case "unknown": + return; + case "array": { + this.push( + `if(!Array.isArray(${v})){${this.appendError(errors, this.error(segs, "an array", failureData))}}`, + ); + if (node.min !== undefined) { + this.push( + `else if(${v}.length<${node.min}){${this.appendError( + errors, + this.error(segs, `at least length ${node.min}`, `${v}.length`), + )}}`, + ); + } + if (node.max !== undefined) { + this.push( + `else if(${v}.length>${node.max}){${this.appendError( + errors, + this.error(segs, `at most length ${node.max}`, `${v}.length`), + )}}`, + ); + } + this.push("else{"); + const index = this.next("i"); + this.push(`for(let ${index}=0;${index}<${v}.length;${index}++){`); + this.emitCollectCheck(node.el, `${v}[${index}]`, [...segs, { d: index }], errors); + this.push("}}"); + return; + } + case "tuple": { + this.push( + `if(!Array.isArray(${v})){${this.appendError(errors, this.error(segs, "an array", failureData))}}else{`, + ); + const requiredPrefix = node.prefix.filter(item => !item.opt && !item.hasDefault).length; + const minimum = requiredPrefix + node.postfix.length; + const maximum = node.prefix.length + node.postfix.length; + if (minimum > 0) { + this.push( + `if(${v}.length<${minimum}){${this.appendError( + errors, + this.error(segs, `an array of at least length ${minimum}`, failureData), + )}}else{`, + ); + } + if (node.variadic === undefined) { + this.push( + `if(${v}.length>${maximum}){${this.appendError( + errors, + this.error(segs, `an array of at most length ${maximum}`, failureData), + )}}else{`, + ); + } + let postfixStart = `${v}.length`; + if (node.postfix.length > 0) { + postfixStart = this.next("p"); + this.push(`const ${postfixStart}=${v}.length-${node.postfix.length};`); + } + let prefixCount = String(node.prefix.length); + if (requiredPrefix !== node.prefix.length) { + prefixCount = this.next("n"); + this.push(`const ${prefixCount}=Math.min(${node.prefix.length},${postfixStart});`); + } + for (let index = 0; index < node.prefix.length; index++) { + if (index >= requiredPrefix) this.push(`if(${index}<${prefixCount}){`); + this.emitCollectCheck(node.prefix[index].val, `${v}[${index}]`, [...segs, { d: String(index) }], errors); + if (index >= requiredPrefix) this.push("}"); + } + if (node.variadic !== undefined) { + const index = this.next("i"); + this.push(`for(let ${index}=${prefixCount};${index}<${postfixStart};${index}++){`); + this.emitCollectCheck(node.variadic, `${v}[${index}]`, [...segs, { d: index }], errors); + this.push("}"); + } + for (let index = 0; index < node.postfix.length; index++) { + const inputIndex = index === 0 ? postfixStart : `${postfixStart}+${index}`; + this.emitCollectCheck(node.postfix[index], `${v}[${inputIndex}]`, [...segs, { d: inputIndex }], errors); + } + if (node.variadic === undefined) this.push("}"); + if (minimum > 0) this.push("}"); + this.push("}"); + return; + } + case "object": { + if ( + node.patternIndexes !== undefined || + node.symbolIndex !== undefined || + node.props.some(prop => typeof prop.key === "symbol") + ) { + this.emitCollectDelegate(node, v, segs, errors); + return; + } + this.push( + `if(typeof ${v}!=="object"||${v}===null){${this.appendError( + errors, + this.error(segs, "an object", failureData), + )}}else{`, + ); + for (const prop of node.props) { + const present = `${this.lit(prop.key)} in ${v}`; + const propSegs: PathSeg[] = [...segs, { s: prop.key }]; + if (prop.opt || prop.hasDefault) { + this.push(`if(${present}){`); + this.emitCollectCheck(prop.val, this.access(v, prop.key), propSegs, errors); + this.push("}"); + } else { + this.push( + `if(!(${present})){${this.appendError( + errors, + this.error(propSegs, expectedOf(prop.val), "M"), + )}}else{`, + ); + this.emitCollectCheck(prop.val, this.access(v, prop.key), propSegs, errors); + this.push("}"); + } + } + if (node.index !== undefined) { + const key = this.next("k"); + this.push(`for(const ${key} in ${v})if(own.call(${v},${key})){`); + this.emitCollectCheck(node.index, `${v}[${key}]`, [...segs, { d: key }], errors); + this.push("}"); + } else if (node.extras === "reject") { + const key = this.next("k"); + this.push( + `for(const ${key} in ${v})if(own.call(${v},${key})&&!(${this.declaredCheck(node.props, key)})){`, + ); + this.push(this.appendError(errors, this.error([...segs, { d: key }], "removed", `${v}[${key}]`))); + this.push("}"); + } + if (node.extras === "reject") { + const symbol = this.next("s"); + this.push( + `for(const ${symbol} of Object.getOwnPropertySymbols(${v}))if(Object.prototype.propertyIsEnumerable.call(${v},${symbol})&&!(${this.declaredCheck(node.props, symbol)})){${this.appendError(errors, this.error([...segs, { d: symbol }], "removed", `${v}[${symbol}]`))}}`, + ); + } + this.push("}"); + return; + } + case "union": { + const failure = node.members.some(canRefineUnionFailure) + ? `UF(${this.ref(node)},${failureData},${this.pathExpr(segs)},${JSON.stringify(expectedOf(node))})` + : this.error(segs, expectedOf(node), failureData); + this.push(`if(!(${this.predicate(node, v)})){${this.appendError(errors, failure)}}`); + return; + } + case "string": { + this.push( + `if(typeof ${v}!=="string"){${this.appendError(errors, this.error(segs, "a string", failureData))}}`, + ); + if (node.min !== undefined) { + this.push( + `else if(${v}.length<${node.min}){${this.appendError( + errors, + this.error(segs, `at least length ${node.min}`, `${v}.length`), + )}}`, + ); + } + if (node.max !== undefined) { + this.push( + `else if(${v}.length>${node.max}){${this.appendError( + errors, + this.error(segs, `at most length ${node.max}`, `${v}.length`), + )}}`, + ); + } + if (node.url) { + this.push( + `else if(!URL.canParse(${v})){${this.appendError( + errors, + this.error(segs, "a URL string", failureData), + )}}`, + ); + } + return; + } + case "number": { + this.push( + `if(typeof ${v}!=="number"||!Number.isFinite(${v})){${this.appendError( + errors, + this.error(segs, node.int ? "an integer" : "a number", failureData), + )}}else{`, + ); + if (node.int) { + this.push( + `if(!Number.isInteger(${v})){${this.appendError(errors, this.error(segs, "an integer", failureData))}}`, + ); + } + if (node.divisor !== undefined) { + this.push( + `if(${v}%${node.divisor}!==0){${this.appendError( + errors, + this.error(segs, `a number divisible by ${node.divisor}`, failureData), + )}}`, + ); + } + if (node.min !== undefined) { + const expected = + node.min === 0 + ? node.xmin + ? "positive" + : "non-negative" + : `a number ${node.xmin ? "more than" : "at least"} ${node.min}`; + this.push( + `if(${v}${node.xmin ? "<=" : "<"}${node.min}){${this.appendError( + errors, + this.error(segs, expected, failureData), + )}}`, + ); + } + if (node.max !== undefined) { + const expected = + node.max === 0 + ? node.xmax + ? "negative" + : "non-positive" + : `a number ${node.xmax ? "less than" : "at most"} ${node.max}`; + this.push( + `if(${v}${node.xmax ? ">=" : ">"}${node.max}){${this.appendError( + errors, + this.error(segs, expected, failureData), + )}}`, + ); + } + this.push("}"); + return; + } + case "lit": + if ( + (node.v !== null && typeof node.v === "object" && !(node.v instanceof Date)) || + typeof node.v === "function" + ) { + this.emitCollectDelegate(node, v, segs, errors); + } else { + this.push( + `if(!(${this.predicate(node, v)})){${this.appendError( + errors, + this.error(segs, expectedOf(node), failureData), + )}}`, + ); + } + return; + case "intersection": + case "sub": + this.emitCollectDelegate(node, v, segs, errors); + return; + case "null": + case "undefined": + case "boolean": + case "bigint": + case "symbol": + case "never": + case "anyobject": + case "instance": + this.push( + `if(!(${this.predicate(node, v)})){${this.appendError( + errors, + this.error(segs, expectedOf(node), failureData), + )}}`, + ); + return; + default: + this.emitCollectDelegate(node, v, segs, errors); + } + } + emitTupleShape( node: TupleIR, v: string, segs: PathSeg[], + errors: string, + brk: string, failureData: string, ): { postfixStart: string; prefixCount: string; requiredPrefix: number } { - this.push(`if(!Array.isArray(${v}))${this.fail(segs, "an array", failureData)};`); + this.push( + `if(!Array.isArray(${v})){${this.appendError(errors, this.error(segs, "an array", failureData))}break ${brk};}`, + ); const requiredPrefix = node.prefix.filter(item => !item.opt && !item.hasDefault).length; const minimum = requiredPrefix + node.postfix.length; if (minimum > 0) { this.push( - `if(${v}.length<${minimum})${this.fail(segs, `an array of at least length ${minimum}`, failureData)};`, + `if(${v}.length<${minimum}){${this.appendError( + errors, + this.error(segs, `an array of at least length ${minimum}`, failureData), + )}break ${brk};}`, ); } if (node.variadic === undefined) { const maximum = node.prefix.length + node.postfix.length; this.push( - `if(${v}.length>${maximum})${this.fail(segs, `an array of at most length ${maximum}`, failureData)};`, + `if(${v}.length>${maximum}){${this.appendError( + errors, + this.error(segs, `an array of at most length ${maximum}`, failureData), + )}break ${brk};}`, ); } let postfixStart = `${v}.length`; @@ -291,143 +624,63 @@ class Builder { return { postfixStart, prefixCount, requiredPrefix }; } - /** Statement-form check for a morph-free subtree with precise error paths. */ - emitCheck(node: IR, v: string, segs: PathSeg[], failureData = v): void { - switch (node.k) { - case "unknown": - return; - case "array": { - let head = `Array.isArray(${v})`; - if (node.min !== undefined) head += `&&${v}.length>=${node.min}`; - if (node.max !== undefined) head += `&&${v}.length<=${node.max}`; - this.push(`if(!(${head}))${this.fail(segs, expectedOf(node), failureData)};`); - const i = this.next("i"); - const x = this.next("x"); - this.push(`for(let ${i}=0;${i}<${v}.length;${i}++){const ${x}=${v}[${i}];`); - this.emitCheck(node.el, x, [...segs, { d: i }]); - this.push("}"); - return; - } - case "tuple": { - const { postfixStart, prefixCount, requiredPrefix } = this.emitTupleShape(node, v, segs, failureData); - for (let index = 0; index < node.prefix.length; index++) { - if (index >= requiredPrefix) this.push(`if(${index}<${prefixCount}){`); - this.emitCheck(node.prefix[index].val, `${v}[${index}]`, [...segs, { d: String(index) }]); - if (index >= requiredPrefix) this.push("}"); - } - if (node.variadic !== undefined) { - const index = this.next("i"); - this.push(`for(let ${index}=${prefixCount};${index}<${postfixStart};${index}++){`); - this.emitCheck(node.variadic, `${v}[${index}]`, [...segs, { d: index }]); - this.push("}"); - } - for (let index = 0; index < node.postfix.length; index++) { - const inputIndex = index === 0 ? postfixStart : `${postfixStart}+${index}`; - this.emitCheck(node.postfix[index], `${v}[${inputIndex}]`, [...segs, { d: inputIndex }]); - } - return; - } - case "object": { - this.push( - `if(typeof ${v}!=="object"||${v}===null||Array.isArray(${v}))${this.fail(segs, "an object", failureData)};`, - ); - for (const p of node.props) { - const present = `${JSON.stringify(p.key)} in ${v}`; - const propSegs: PathSeg[] = [...segs, { s: p.key }]; - if (p.opt || p.hasDefault) { - this.push(`if(${present}){`); - this.emitCheck(p.val, access(v, p.key), propSegs); - this.push("}"); - } else { - this.push(`if(!(${present}))${this.fail(propSegs, expectedOf(p.val), "M")};`); - this.emitCheck(p.val, access(v, p.key), propSegs); - } - } - if (node.index) { - const k = this.next("k"); - this.push(`for(const ${k} in ${v})if(own.call(${v},${k})){`); - this.emitCheck(node.index, `${v}[${k}]`, [...segs, { d: k }]); - this.push("}"); - } else if (node.extras === "reject") { - const k = this.next("k"); - this.push( - `for(const ${k} in ${v})if(own.call(${v},${k})&&!(${this.declaredCheck(node.props, k)}))${this.fail( - [...segs, { d: k }], - "removed (undeclared key)", - `${v}[${k}]`, - )};`, - ); - } - return; - } - case "sub": - this.emitDelegate(node, v, segs); - return; - case "union": { - const failure = node.members.some(canRefineUnionFailure) - ? `return UF(${this.ref(node)},${failureData},${this.pathExpr(segs)},${JSON.stringify(expectedOf(node))})` - : this.fail(segs, expectedOf(node), failureData); - const literals = node.members.filter(isPrimitiveLiteral); - if (literals.length === node.members.length && literals.length >= 4) { - const cases = literals.map(member => `case ${this.lit(member.v)}:`).join(""); - this.push(`switch(${v}){${cases}break;default:${failure};}`); - } else { - this.push(`if(!(${this.predicate(node, v)}))${failure};`); - } - return; - } - case "null": - case "undefined": - case "boolean": - case "bigint": - case "symbol": - case "never": - case "anyobject": - case "lit": - case "string": - case "number": - case "instance": - this.push(`if(!(${this.predicate(node, v)}))${this.fail(segs, expectedOf(node), failureData)};`); - return; - case "refine": { - this.emitCheck(node.base, v, segs, failureData); - const failure = this.fail(segs, node.expected, v); - this.push(`try{if(!${this.ref(node.pred)}(${v}))${failure};}catch{${failure};}`); - return; - } - case "intersection": - for (const member of node.members) this.emitCheck(member, v, segs, failureData); - return; - default: - this.emitDelegate(node, v, segs); + /** Fill `target` with a validated default (factory output revalidated per call). */ + emitDefaultFill(val: IR, def: unknown, isFactory: boolean, target: string, segs: PathSeg[], errors: string): void { + if (isFactory && typeof def === "function") { + const candidate = this.next("d"); + const resolved = this.next("t"); + const label = this.next("L"); + this.push(`const ${candidate}=${this.ref(def)}();let ${resolved};${label}:{`); + this.emitCollectProduce(val, candidate, segs, resolved, errors, label); + this.push(`${target}=${resolved};}`); + } else { + // Static defaults were prevalidated at construction; MD clones + // mutable payloads so callers cannot alias the schema's copy. + this.push(`${target}=${litSource(def) ?? `MD(${this.ref(def)})`};`); } } /** * Validate `v` against a morphing subtree and assign the produced output - * to `out` (an already-declared `let`). + * to `out` (an already-declared `let`). Failures append to `errors` and + * `break ${brk}` (skipping the output assignment), mirroring interp: an + * error in one child never suppresses sibling validation or morphs. */ - emitProduce(node: IR, v: string, segs: PathSeg[], out: string, failureData = v): void { + emitCollectProduce( + node: IR, + v: string, + segs: PathSeg[], + out: string, + errors: string, + brk: string, + failureData = v, + ): void { + if (node.cfg !== undefined || node.k === "refine") { + this.emitCollectDelegate(node, v, segs, errors, brk, out); + return; + } if (!hasMorph(node)) { - this.emitCheck(node, v, segs, failureData); + this.emitCollectCheck(node, v, segs, errors, failureData); this.push(`${out}=${v};`); return; } switch (node.k) { case "sub": - this.emitDelegate(node, v, segs, out); + this.emitCollectDelegate(node, v, segs, errors, brk, out); return; case "morph": { const input = this.next("t"); this.push(`let ${input};`); - this.emitProduce(node.input, v, segs, input, failureData); + const mark = this.markErrors(errors); + this.emitCollectProduce(node.input, v, segs, input, errors, brk, failureData); + this.guardGrowth(errors, mark, brk); const context = this.next("c"); const result = this.next("r"); this.push(`const ${context}=new MC(${this.storedPathExpr(segs)},${input});`); this.push(`const ${result}=${this.ref(node.fn)}(${input},${context});`); - this.push(`if(${result} instanceof AE)return ${result};`); + this.push(`if(${result} instanceof AE){${this.appendError(errors, result)}break ${brk};}`); if (node.out === undefined) this.push(`${out}=${result};`); - else this.emitProduce(node.out, result, segs, out); + else this.emitCollectProduce(node.out, result, segs, out, errors, brk); return; } case "alias": { @@ -437,12 +690,12 @@ class Builder { this.#activeAliases = active; } if (active.has(node)) { - this.emitDelegate(node, v, segs, out); + this.emitCollectDelegate(node, v, segs, errors, brk, out); return; } active.add(node); try { - this.emitProduce(node.resolve(), v, segs, out, failureData); + this.emitCollectProduce(node.resolve(), v, segs, out, errors, brk, failureData); } finally { active.delete(node); } @@ -467,45 +720,77 @@ class Builder { } } this.push("}"); - this.push( - `if(!${ok})return UF(${this.ref(node)},${failureData},${this.pathExpr(segs)},${JSON.stringify(expectedOf(node))});`, - ); + const failure = `UF(${this.ref(node)},${failureData},${this.pathExpr(segs)},${JSON.stringify(expectedOf(node))})`; + this.push(`if(!${ok}){${this.appendError(errors, failure)}break ${brk};}`); return; } case "array": { - let head = `Array.isArray(${v})`; - if (node.min !== undefined) head += `&&${v}.length>=${node.min}`; - if (node.max !== undefined) head += `&&${v}.length<=${node.max}`; - this.push(`if(!(${head}))${this.fail(segs, expectedOf(node), failureData)};`); - const arr = this.next("a"); - const i = this.next("i"); - const x = this.next("x"); - const el = this.next("t"); - this.push(`const ${arr}=new Array(${v}.length);`); - this.push(`for(let ${i}=0;${i}<${v}.length;${i}++){const ${x}=${v}[${i}];let ${el};`); - this.emitProduce(node.el, x, [...segs, { d: i }], el); - this.push(`${arr}[${i}]=${el};}`); - this.push(`${out}=${arr};`); + this.push( + `if(!Array.isArray(${v})){${this.appendError(errors, this.error(segs, "an array", failureData))}break ${brk};}`, + ); + if (node.min !== undefined) { + this.push( + `if(${v}.length<${node.min}){${this.appendError( + errors, + this.error(segs, `at least length ${node.min}`, `${v}.length`), + )}break ${brk};}`, + ); + } + if (node.max !== undefined) { + this.push( + `if(${v}.length>${node.max}){${this.appendError( + errors, + this.error(segs, `at most length ${node.max}`, `${v}.length`), + )}break ${brk};}`, + ); + } + const array = this.next("a"); + const index = this.next("i"); + const input = this.next("x"); + const element = this.next("t"); + const label = this.next("L"); + this.push(`const ${array}=new Array(${v}.length);`); + this.push( + `for(let ${index}=0;${index}<${v}.length;${index}++){const ${input}=${v}[${index}];let ${element};${label}:{`, + ); + this.emitCollectProduce(node.el, input, [...segs, { d: index }], element, errors, label); + this.push(`${array}[${index}]=${element};}}`); + this.push(`${out}=${array};`); return; } case "tuple": { - const { postfixStart, prefixCount, requiredPrefix } = this.emitTupleShape(node, v, segs, failureData); + const { postfixStart, prefixCount, requiredPrefix } = this.emitTupleShape( + node, + v, + segs, + errors, + brk, + failureData, + ); const tuple = this.next("a"); this.push(`const ${tuple}=[...${v}];`); for (let index = 0; index < node.prefix.length; index++) { const item = node.prefix[index]; + const itemSegs: PathSeg[] = [...segs, { s: index }]; const input = `${v}[${index}]`; const output = `${tuple}[${index}]`; + const label = this.next("L"); if (index >= requiredPrefix) this.push(`if(${index}<${prefixCount}){`); - if (hasMorph(item.val)) this.emitProduce(item.val, input, [...segs, { d: String(index) }], output); - else { - this.emitCheck(item.val, input, [...segs, { d: String(index) }]); - this.push(`${output}=${input};`); + this.push(`${label}:{`); + if (hasMorph(item.val)) { + const temporary = this.next("t"); + this.push(`let ${temporary};`); + this.emitCollectProduce(item.val, input, itemSegs, temporary, errors, label); + this.push(`${output}=${temporary};`); + } else { + this.emitCollectCheck(item.val, input, itemSegs, errors); } + this.push("}"); if (index >= requiredPrefix) { if (item.hasDefault) { - const defaultValue = item.defFactory ? `${this.ref(item.def)}()` : this.lit(item.def); - this.push(`}else{${output}=${defaultValue};}`); + this.push("}else{"); + this.emitDefaultFill(item.val, item.def, item.defFactory === true, output, itemSegs, errors); + this.push("}"); } else { this.push("}"); } @@ -514,125 +799,147 @@ class Builder { if (node.variadic !== undefined) { const index = this.next("i"); const input = this.next("x"); + const label = this.next("L"); this.push( - `for(let ${index}=${prefixCount};${index}<${postfixStart};${index}++){const ${input}=${v}[${index}];`, + `for(let ${index}=${prefixCount};${index}<${postfixStart};${index}++){const ${input}=${v}[${index}];${label}:{`, ); if (hasMorph(node.variadic)) { - this.emitProduce(node.variadic, input, [...segs, { d: index }], `${tuple}[${index}]`); + const temporary = this.next("t"); + this.push(`let ${temporary};`); + this.emitCollectProduce(node.variadic, input, [...segs, { d: index }], temporary, errors, label); + this.push(`${tuple}[${index}]=${temporary};`); } else { - this.emitCheck(node.variadic, input, [...segs, { d: index }]); - this.push(`${tuple}[${index}]=${input};`); + this.emitCollectCheck(node.variadic, input, [...segs, { d: index }], errors); } - this.push("}"); + this.push("}}"); } for (let index = 0; index < node.postfix.length; index++) { const inputIndex = index === 0 ? postfixStart : `${postfixStart}+${index}`; const input = `${v}[${inputIndex}]`; - const output = `${tuple}[${inputIndex}]`; const item = node.postfix[index]; - if (hasMorph(item)) this.emitProduce(item, input, [...segs, { d: inputIndex }], output); - else { - this.emitCheck(item, input, [...segs, { d: inputIndex }]); - this.push(`${output}=${input};`); + const label = this.next("L"); + this.push(`${label}:{`); + if (hasMorph(item)) { + const temporary = this.next("t"); + this.push(`let ${temporary};`); + this.emitCollectProduce(item, input, [...segs, { d: inputIndex }], temporary, errors, label); + this.push(`${tuple}[${inputIndex}]=${temporary};`); + } else { + this.emitCollectCheck(item, input, [...segs, { d: inputIndex }], errors); } + this.push("}"); } this.push(`${out}=${tuple};`); return; } case "object": { - this.push( - `if(typeof ${v}!=="object"||${v}===null||Array.isArray(${v}))${this.fail(segs, "an object", failureData)};`, - ); - const o = this.next("o"); - const fresh = node.extras !== "keep" && !node.index; - this.push(fresh ? `const ${o}={};` : `const ${o}={...${v}};`); - for (const p of node.props) { - const present = `${JSON.stringify(p.key)} in ${v}`; - const propSegs: PathSeg[] = [...segs, { s: p.key }]; - const av = access(v, p.key); - const ao = access(o, p.key); - const morphChild = hasMorph(p.val); - const missing: string[] = []; - if (p.hasDefault) { - const dflt = p.defFactory ? `${this.ref(p.def)}()` : this.lit(p.def); - missing.push(`${ao}=${dflt};`); - } else if (!p.opt) { - missing.push(`${this.fail(propSegs, expectedOf(p.val), "M")};`); - } - this.push(`if(!(${present})){${missing.join("")}}else{`); - if (morphChild) { - const t = this.next("t"); - this.push(`let ${t};`); - this.emitProduce(p.val, av, propSegs, t); - this.push(`${ao}=${t};`); - } else { - this.emitCheck(p.val, av, propSegs); - if (fresh) this.push(`${ao}=${av};`); - } - this.push("}"); + if ( + node.patternIndexes !== undefined || + node.symbolIndex !== undefined || + node.props.some(prop => typeof prop.key === "symbol") + ) { + this.emitCollectDelegate(node, v, segs, errors, brk, out); + return; } - if (node.index) { - const k = this.next("k"); - this.push(`for(const ${k} in ${v})if(own.call(${v},${k})){`); - if (hasMorph(node.index)) { - const t = this.next("t"); - this.push(`let ${t};`); - this.emitProduce(node.index, `${v}[${k}]`, [...segs, { d: k }], t); - this.push(`${o}[${k}]=${t};`); - } else { - this.emitCheck(node.index, `${v}[${k}]`, [...segs, { d: k }]); + this.push( + `if(typeof ${v}!=="object"||${v}===null){${this.appendError( + errors, + this.error(segs, "an object", failureData), + )}break ${brk};}`, + ); + const object = this.next("o"); + const fresh = node.extras === "delete" && node.index === undefined; + this.push(fresh ? `const ${object}={};` : `const ${object}={...${v}};`); + for (const prop of node.props) { + const present = `${this.lit(prop.key)} in ${v}`; + const propSegs: PathSeg[] = [...segs, { s: prop.key }]; + const input = this.access(v, prop.key); + const output = this.access(object, prop.key); + const label = this.next("L"); + this.push(`if(!(${present})){`); + if (prop.hasDefault) { + this.emitDefaultFill(prop.val, prop.def, prop.defFactory === true, output, propSegs, errors); + } else if (!prop.opt) { + this.push(this.appendError(errors, this.error(propSegs, expectedOf(prop.val), "M"))); } - this.push("}"); + this.push(`}else{${label}:{`); + if (hasMorph(prop.val)) { + const temporary = this.next("t"); + this.push(`let ${temporary};`); + this.emitCollectProduce(prop.val, input, propSegs, temporary, errors, label); + this.push(`${output}=${temporary};`); + } else { + this.emitCollectCheck(prop.val, input, propSegs, errors); + if (fresh) this.push(`${output}=${input};`); + } + this.push("}}"); + } + if (node.index !== undefined) { + const key = this.next("k"); + const label = this.next("L"); + this.push(`for(const ${key} in ${v})if(own.call(${v},${key})){${label}:{`); + if (hasMorph(node.index)) { + const temporary = this.next("t"); + this.push(`let ${temporary};`); + this.emitCollectProduce(node.index, `${v}[${key}]`, [...segs, { d: key }], temporary, errors, label); + this.push(`${object}[${key}]=${temporary};`); + } else { + this.emitCollectCheck(node.index, `${v}[${key}]`, [...segs, { d: key }], errors); + } + this.push("}}"); } else if (node.extras === "reject") { - const k = this.next("k"); + const key = this.next("k"); this.push( - `for(const ${k} in ${v})if(own.call(${v},${k})&&!(${this.declaredCheck(node.props, k)}))${this.fail( - [...segs, { d: k }], - "removed (undeclared key)", - `${v}[${k}]`, - )};`, + `for(const ${key} in ${v})if(own.call(${v},${key})&&!(${this.declaredCheck(node.props, key)})){${this.appendError( + errors, + this.error([...segs, { d: key }], "removed", `${v}[${key}]`), + )}}`, ); } - this.push(`${out}=${o};`); - return; - } - case "refine": { - const refined = this.next("t"); - this.push(`let ${refined};`); - this.emitProduce(node.base, v, segs, refined, failureData); - const failure = this.fail(segs, node.expected, refined); - this.push(`try{if(!${this.ref(node.pred)}(${refined}))${failure};}catch{${failure};}`); - this.push(`${out}=${refined};`); + if (node.extras === "reject") { + const symbol = this.next("s"); + this.push( + `for(const ${symbol} of Object.getOwnPropertySymbols(${v}))if(Object.prototype.propertyIsEnumerable.call(${v},${symbol})&&!(${this.declaredCheck(node.props, symbol)})){${this.appendError(errors, this.error([...segs, { d: symbol }], "removed", `${v}[${symbol}]`))}}`, + ); + } + this.push(`${out}=${object};`); return; } case "intersection": { const current = this.next("t"); this.push(`let ${current}=${v};`); - for (const member of node.members) { - if (hasMorph(member)) this.emitProduce(member, current, segs, current); - else this.emitCheck(member, current, segs); + const mark = this.markErrors(errors); + for (let index = 0; index < node.members.length; index++) { + if (index > 0) this.guardGrowth(errors, mark, brk); + const member = node.members[index]; + if (hasMorph(member)) this.emitCollectProduce(member, current, segs, current, errors, brk); + else this.emitCollectCheck(member, current, segs, errors); } this.push(`${out}=${current};`); return; } default: - this.emitDelegate(node, v, segs, out); + this.emitCollectDelegate(node, v, segs, errors, brk, out); } } build(ir: IR): (value: unknown) => unknown { + const errors = this.next("e"); let ret: string; if (hasMorph(ir)) { - this.push("let o;"); - // body emitted below needs `o` declared first, so splice ordering: - this.emitProduce(ir, "v", [], "o"); + const label = this.next("L"); + this.push(`let ${errors};let o;${label}:{`); + this.emitCollectProduce(ir, "v", [], "o", errors, label); + this.push("}"); ret = "o"; } else { - this.emitCheck(ir, "v", []); + this.push(`let ${errors};`); + this.emitCollectCheck(ir, "v", [], errors); ret = "v"; } + this.push(`if(${errors}!==undefined)return ${errors};`); const src = `return function(v){${this.#lines.join("")}return ${ret}}`; - const make = new Function("R", "AE", "M", "PF", "UF", "MC", "own", src) as ( + const make = new Function("R", "AE", "M", "PF", "UF", "MC", "own", "MD", src) as ( refs: unknown[], ae: typeof OmpErrors, m: typeof MISSING, @@ -640,8 +947,18 @@ class Builder { uf: typeof unionFail, mc: typeof CompiledMorphContext, ownFn: typeof own, + md: typeof materializeDefault, ) => (value: unknown) => unknown; - return make(this.#refs, OmpErrors, MISSING, prefixErrors, unionFail, CompiledMorphContext, own); + return make( + this.#refs, + OmpErrors, + MISSING, + prefixErrors, + unionFail, + CompiledMorphContext, + own, + materializeDefault, + ); } emitAllows(node: IR, v: string): void { @@ -659,13 +976,11 @@ class Builder { } case "object": { const object = this.next("o"); - this.push( - `const ${object}=${v};if(typeof ${object}!=="object"||${object}===null||Array.isArray(${object}))return false;`, - ); + this.push(`const ${object}=${v};if(typeof ${object}!=="object"||${object}===null)return false;`); for (const prop of node.props) { const value = this.next("p"); - const present = `${JSON.stringify(prop.key)} in ${object}`; - this.push(`const ${value}=${access(object, prop.key)};`); + const present = `${this.lit(prop.key)} in ${object}`; + this.push(`const ${value}=${this.access(object, prop.key)};`); if (prop.opt || prop.hasDefault) { if (rejectsUndefined(prop.val)) { this.push(`if(${value}!==undefined){`); @@ -681,16 +996,42 @@ class Builder { this.emitAllows(prop.val, value); } } - if (node.index) { - const key = this.next("k"); - this.push(`for(const ${key} in ${object}){if(!own.call(${object},${key}))continue;`); - this.emitAllows(node.index, `${object}[${key}]`); + const stringKey = this.next("k"); + if (node.index !== undefined) { + this.push(`for(const ${stringKey} in ${object}){if(!own.call(${object},${stringKey}))continue;`); + this.emitAllows(node.index, `${object}[${stringKey}]`); this.push("}"); - } else if (node.extras === "reject") { - const key = this.next("k"); + } + if (node.patternIndexes !== undefined) { + for (const pattern of node.patternIndexes) { + this.push( + `for(const ${stringKey} in ${object})if(own.call(${object},${stringKey})&&(${this.predicate(pattern.key, stringKey)})&&!(${this.predicate(pattern.val, `${object}[${stringKey}]`)}))return false;`, + ); + } + } + if (node.symbolIndex !== undefined) { + const symbol = this.next("s"); this.push( - `for(const ${key} in ${object})if(own.call(${object},${key})&&!(${this.declaredCheck(node.props, key)}))return false;`, + `for(const ${symbol} of Object.getOwnPropertySymbols(${object})){if(!Object.prototype.propertyIsEnumerable.call(${object},${symbol}))continue;`, ); + this.emitAllows(node.symbolIndex, `${object}[${symbol}]`); + this.push("}"); + } + if (node.extras === "reject") { + const patternMatch = + node.patternIndexes?.map(pattern => `(${this.predicate(pattern.key, stringKey)})`).join("||") ?? + "false"; + if (node.index === undefined) { + this.push( + `for(const ${stringKey} in ${object})if(own.call(${object},${stringKey})&&!(${this.declaredCheck(node.props, stringKey)})&&!(${patternMatch}))return false;`, + ); + } + if (node.symbolIndex === undefined) { + const symbol = this.next("s"); + this.push( + `for(const ${symbol} of Object.getOwnPropertySymbols(${object}))if(Object.prototype.propertyIsEnumerable.call(${object},${symbol})&&!(${this.declaredCheck(node.props, symbol)}))return false;`, + ); + } } return; } @@ -716,14 +1057,14 @@ class Builder { } } - buildAllows(ir: IR): (value: unknown) => boolean { + buildAllows(ir: IR): (value: unknown) => value is unknown { this.emitAllows(ir, "v"); const src = `return function(v){${this.#lines.join("")}return true}`; const make = new Function("R", "AE", "own", src) as ( refs: unknown[], ae: typeof OmpErrors, ownFn: typeof own, - ) => (value: unknown) => boolean; + ) => (value: unknown) => value is unknown; return make(this.#refs, OmpErrors, own); } } @@ -736,15 +1077,15 @@ function prefixErrors(errs: OmpErrors, parts: PropertyKey[]): OmpErrors { const kWalk = Symbol("omptype.boundWalk"); interface WalkTagged { - [kWalk]?: (value: unknown) => unknown; + [kWalk]?: (value: unknown, path?: PropertyKey[]) => unknown; } /** Cached interpreter closure for recursive aliases and predicate-only fallbacks. */ -function boundWalk(node: IR): (value: unknown) => unknown { +function boundWalk(node: IR): (value: unknown, path?: PropertyKey[]) => unknown { const tagged = node as IR & WalkTagged; let fn = tagged[kWalk]; if (!fn) { - fn = (value: unknown) => walk(node, value); + fn = (value: unknown, path?: PropertyKey[]) => walk(node, value, path); tagged[kWalk] = fn; } return fn; @@ -755,26 +1096,40 @@ function resolvedRoot(ir: IR): IR { } const compiledCache = new WeakMap unknown>(); -const allowsCache = new WeakMap boolean>(); +const allowsCache = new WeakMap value is unknown>(); /** Compile `ir` into a specialized validator. */ export function compile(ir: IR): (value: unknown) => unknown { const root = resolvedRoot(ir); - let validator = compiledCache.get(root); + const validator = compiledCache.get(root); if (validator === undefined) { - validator = new Builder().build(root); - compiledCache.set(root, validator); + // Publish a deferred wrapper before building: recursive schemas re-enter + // compile() for the same root mid-build (e.g. an alias element inside an + // array), and each re-entry must reuse this build instead of starting a + // fresh one forever. The wrapper resolves to the built validator by call + // time; the interpreter is a safety net that never triggers post-build. + let built: ((value: unknown) => unknown) | undefined; + compiledCache.set(root, value => (built === undefined ? walk(root, value) : built(value))); + built = new Builder().build(root); + compiledCache.set(root, built); + return built; } return validator; } /** Compile `ir` into an allocation-free boolean validator. */ -export function compileAllows(ir: IR): (value: unknown) => boolean { +export function compileAllows(ir: IR): (value: unknown) => value is unknown { const root = resolvedRoot(ir); - let validator = allowsCache.get(root); + const validator = allowsCache.get(root); if (validator === undefined) { - validator = new Builder().buildAllows(root); - allowsCache.set(root, validator); + let built: ((value: unknown) => value is unknown) | undefined; + allowsCache.set(root, ((value: unknown) => + built === undefined ? !(walk(root, value) instanceof OmpErrors) : built(value)) as ( + value: unknown, + ) => value is unknown); + built = new Builder().buildAllows(root); + allowsCache.set(root, built); + return built; } return validator; } @@ -785,9 +1140,9 @@ export function compileToSource(ir: IR): string { const builder = new Builder(); if (hasMorph(root)) { builder.push("let o;"); - builder.emitProduce(root, "v", [], "o"); + builder.emitCollectProduce(root, "v", [], "o", "e", "L0"); return `function(v){/* refs elided */return o}`; } - builder.emitCheck(root, "v", []); + builder.emitCollectCheck(root, "v", [], "e"); return `function(v){/* refs elided */return v}`; } diff --git a/packages/omptype/src/errors.ts b/packages/omptype/src/errors.ts index d9e3b1dbd..854e11148 100644 --- a/packages/omptype/src/errors.ts +++ b/packages/omptype/src/errors.ts @@ -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 (was )`. */ @@ -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(); + 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 { #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 { 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 { 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> { - const entry = this.#getEntry(); - return { [entry.path.map(String).join(".")]: entry }; + const result: Record = {}; + for (const entry of this) result[entry.path.map(String).join(".")] = entry; + return result; } - /** Transform the failure entry into a plain array. */ map(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 { - 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); } diff --git a/packages/omptype/src/infer.ts b/packages/omptype/src/infer.ts index 2b5de955e..779ef2805 100644 --- a/packages/omptype/src/infer.ts +++ b/packages/omptype/src/infer.ts @@ -13,20 +13,42 @@ type Trim = TrimLeft>; * `never` is intentionally absent: the parser rejects it and the fallback in * `InferMember` treats a missing entry as "not a primitive". */ +type ArkAny = ReturnType; + 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; + Map: Map; + WeakSet: WeakSet; + WeakMap: WeakMap; + Promise: Promise; + FormData: FormData; + "object.json": unknown; true: true; false: false; } @@ -39,7 +61,7 @@ type Merge = left extends object type InferUtility = s extends `Record<${string},${infer value}>` ? Record> - : s extends `Array<${infer element}>` + : s extends `Array<${infer element}>` | `Array.liftFrom<${infer element}>` ? InferString[] : s extends `Partial<${infer value}>` ? Partial> @@ -66,11 +88,15 @@ type InferParse = 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 + : 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 = "[string]" extends keyof def /** Object-literal inference used by fluent composition overloads. */ export type InferObjectDef = InferObject; -type InferLiteralDef = def extends string - ? InferString - : def extends object - ? InferObjectLiteral - : unknown; +type InferLiteralDef = def extends { readonly infer: infer output } + ? output + : def extends string + ? InferString + : def extends object + ? InferObjectLiteral + : unknown; + +type InferLiteralDefIn = def extends { readonly inferIn: infer input } + ? input + : def extends string + ? InferStringIn + : def extends object + ? InferObjectLiteralIn + : unknown; type LiteralRequired = { -readonly [key in DefinitionKeys as IsOptionalProp extends true @@ -281,9 +317,28 @@ type LiteralOptional = { : never]?: InferLiteralDef>; }; -/** Object-literal-only inference that does not inspect embedded schema internals. */ +type LiteralRequiredIn = { + -readonly [key in DefinitionKeys as IsOptionalProp extends true + ? never + : HasDefault extends true + ? never + : PropName]-?: InferLiteralDefIn>; +}; + +type LiteralOptionalIn = { + -readonly [key in DefinitionKeys as IsOptionalProp extends true + ? PropName + : HasDefault extends true + ? PropName + : never]?: InferLiteralDefIn>; +}; + +/** Object-literal inference that unwraps embedded schema values one level deep. */ export type InferObjectLiteral = Simplify & LiteralOptional>; +/** Input-side object-literal inference (embedded schemas contribute `inferIn`). */ +export type InferObjectLiteralIn = Simplify & LiteralOptionalIn>; + type InstanceOf = ctor extends abstract new (...args: never[]) => infer instance ? instance : never; type SpreadOutput = InferDef extends readonly (infer element)[] ? element[] : never[]; type SpreadInput = InferDefIn extends readonly (infer element)[] ? element[] : never[]; diff --git a/packages/omptype/src/interp.ts b/packages/omptype/src/interp.ts index 94a0629e5..c3be10ef7 100644 --- a/packages/omptype/src/interp.ts +++ b/packages/omptype/src/interp.ts @@ -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> | undefined; +let activeChecks: WeakMap> | 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; + if (typeof v !== "object" || v === null) return false; + const rec = v as Record; 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(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(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; + if (typeof v !== "object" || v === null) return fail(path, "an object", v); + const rec = v as Record; const morph = hasMorph(ir); - let out: Record | undefined; + let out: Record | 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(); + 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; - 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 { + 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()): 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)[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. */ diff --git a/packages/omptype/src/ir.ts b/packages/omptype/src/ir.ts index 15f1011f9..f43c59b17 100644 --- a/packages/omptype/src/ir.ts +++ b/packages/omptype/src/ir.ts @@ -10,16 +10,30 @@ * and the JSON Schema emitter (`json-schema.ts`). */ -import { OmpErrors, OmpTypeError } from "./errors"; +import { type ErrorConfig, OmpErrors, OmpTypeError } from "./errors"; import { keywordIR, patternIR, templateIR } from "./keywords"; /** Brand carried by `Type` instances so the parser can embed them in defs. */ export const IR_BRAND: unique symbol = Symbol("omptype.schema"); const kMorph: unique symbol = Symbol("omptype.hasMorph"); +const kMorphOwner: unique symbol = Symbol("omptype.hasMorphOwner"); +const kAlias: unique symbol = Symbol("omptype.hasAlias"); +const kAliasOwner: unique symbol = Symbol("omptype.hasAliasOwner"); +const kSimple: unique symbol = Symbol("omptype.simple"); +const kSimpleOwner: unique symbol = Symbol("omptype.simpleOwner"); interface IRAnalysis { [kMorph]?: boolean; + [kMorphOwner]?: object; + [kAlias]?: boolean; + [kAliasOwner]?: object; + [kSimple]?: boolean; + [kSimpleOwner]?: object; + /** Node-local metadata used for shallow error formatting. */ + cfg?: ErrorConfig; + /** True when `desc` was derived from the node itself rather than authored via `.describe()`. */ + descAuto?: boolean; } /** @@ -39,10 +53,13 @@ export interface EmbeddableSchema { /** `.default()` payload; a function is a factory invoked per fill. */ defaultValue?: unknown; hasDefault: boolean; + /** Precomputed output for a non-factory default after validation and morphs. */ + defaultOutput?: unknown; + hasDefaultOutput?: boolean; /** `.describe()` annotation, emitted into JSON Schema. */ description?: string; /** Full validate+morph pipeline (identical to calling the schema). */ - run(value: unknown): unknown; + run(value: unknown, path?: readonly PropertyKey[]): unknown; } /** Policy for undeclared object keys. */ @@ -66,6 +83,8 @@ export interface TupleItemIR { def?: unknown; defFactory?: boolean; hasDefault?: boolean; + /** True once the default has been validated and static morph output precomputed. */ + defValidated?: boolean; } /** Fixed, optional, variadic, and postfix tuple sequence. */ @@ -104,11 +123,19 @@ export type IR = IRAnalysis & | { k: "intersection"; members: IR[]; desc?: string } | { k: "array"; el: IR; min?: number; max?: number; desc?: string } | TupleIR - | { k: "object"; props: PropIR[]; index?: IR; extras: Extras; desc?: string } + | { + k: "object"; + props: PropIR[]; + index?: IR; + symbolIndex?: IR; + patternIndexes?: { key: IR; val: IR }[]; + extras: Extras; + desc?: string; + } | { k: "refine"; base: IR; - pred: (value: unknown) => boolean; + pred: (value: unknown) => boolean | OmpErrors; expected: string; json?: Record; desc?: string; @@ -127,13 +154,15 @@ export type IR = IRAnalysis & ); export interface PropIR { - key: string; + key: PropertyKey; opt: boolean; val: IR; /** Default payload (value, or factory when `defFactory`); missing key is filled. */ def?: unknown; defFactory?: boolean; hasDefault?: boolean; + /** True once the default has been validated and static morph output precomputed. */ + defValidated?: boolean; } /** Definition input accepted by `type()` and object property values. */ @@ -144,11 +173,13 @@ export type Def = string | RegExp | Date | EmbeddableSchema | readonly unknown[] type Tok = | { t: "id"; v: string } | { t: "num"; v: number } + | { t: "bigint"; v: bigint } | { t: "date"; v: Date } + | { t: "regex"; v: RegExp } | { t: "str"; v: string } | { t: "op"; v: string }; -const SIMPLE_OPS = "|()[]=?%"; +const SIMPLE_OPS = "|&()[]=?%,#"; function tokenize(src: string): Tok[] { const toks: Tok[] = []; @@ -160,28 +191,63 @@ function tokenize(src: string): Tok[] { i++; continue; } - if (c === "'" || c === '"') { - let j = i + 1; - while (j < n && src[j] !== c) j++; - if (j >= n) throw new OmpTypeError(`unterminated string literal in "${src}"`); - toks.push({ t: "str", v: src.slice(i + 1, j) }); - i = j + 1; - continue; - } if (c === "d" && (src[i + 1] === "'" || src[i + 1] === '"')) { const quote = src[i + 1]; const end = src.indexOf(quote, i + 2); if (end < 0) throw new OmpTypeError(`unterminated date literal in "${src}"`); - const value = new Date(src.slice(i + 2, end)); + const source = src.slice(i + 2, end).trim(); + const value = /^\d+$/.test(source) ? new Date(Number(source)) : new Date(source); if (Number.isNaN(value.valueOf())) throw new OmpTypeError(`invalid date literal in "${src}"`); toks.push({ t: "date", v: value }); i = end + 1; continue; } + if (c === "'" || c === '"') { + let j = i + 1; + let value = ""; + for (; j < n && src[j] !== c; j++) { + if (src[j] === "\\") { + j++; + if (j >= n) break; + } + value += src[j]; + } + if (j >= n) throw new OmpTypeError(`unterminated string literal in "${src}"`); + toks.push({ t: "str", v: value }); + i = j + 1; + continue; + } + if (c === "/") { + let j = i + 1; + for (; j < n; j++) { + if (src[j] === "\\") j++; + else if (src[j] === "/") break; + } + if (j >= n) throw new OmpTypeError(`unterminated regular expression in "${src}"`); + let end = j + 1; + while (end < n && /[dgimsuvy]/.test(src[end])) end++; + const source = src.slice(i + 1, j); + const flags = src.slice(j + 1, end); + try { + toks.push({ t: "regex", v: new RegExp(source, flags) }); + } catch { + throw new OmpTypeError(`invalid regular expression "${src.slice(i, end)}"`); + } + i = end; + continue; + } if ((c >= "0" && c <= "9") || (c === "-" && i + 1 < n && src[i + 1] >= "0" && src[i + 1] <= "9")) { let j = i + 1; - while (j < n && ((src[j] >= "0" && src[j] <= "9") || src[j] === "." || src[j] === "e" || src[j] === "+")) j++; - toks.push({ t: "num", v: Number(src.slice(i, j)) }); + while (j < n && /[\w.+-]/.test(src[j])) j++; + const raw = src.slice(i, j); + if (/^-?(?:0|[1-9]\d*)n$/.test(raw) && raw !== "-0n") { + toks.push({ t: "bigint", v: BigInt(raw.slice(0, -1)) }); + } else { + const valid = + /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(raw) && !Object.is(Number(raw), -0) && String(Number(raw)) === raw; + if (!valid) throw new OmpTypeError(`Malformed number literal '${raw}'`); + toks.push({ t: "num", v: Number(raw) }); + } i = j; continue; } @@ -202,6 +268,11 @@ function tokenize(src: string): Tok[] { } continue; } + if (c === "=" && src[i + 1] === "=") { + toks.push({ t: "op", v: "==" }); + i += 2; + continue; + } if (SIMPLE_OPS.includes(c)) { toks.push({ t: "op", v: c }); i++; @@ -233,8 +304,24 @@ const KEYWORDS: Record IR> = { false: () => ({ k: "lit", v: false }), }; -/** Resolve a named scope alias to its lazy IR reference. */ -export type AliasResolver = (name: string) => IR | undefined; +/** Resolve named scope aliases and, when present, scoped generic invocations. */ +export interface AliasResolver { + (name: string): IR | undefined; + hasGeneric?(name: string): boolean; + generic?(name: string, arguments_: readonly IR[]): IR | undefined; +} + +/** + * Resolvers that only intercept the `this` self-reference. A parse under such + * a resolver of a source with no `this` token is identical to a resolver-free + * parse, so it may read and populate the string-definition cache. + */ +const THIS_ONLY_RESOLVERS = new WeakSet(); + +/** Declare that `resolve` only intercepts `this` (see THIS_ONLY_RESOLVERS). */ +export function markThisOnlyResolver(resolve: AliasResolver): void { + THIS_ONLY_RESOLVERS.add(resolve); +} interface ParsedTop { ir: IR; @@ -282,9 +369,10 @@ class StrParser { let hasDefault = false; if (this.#eatOp("=")) { const t = this.#next(); - if (t.t === "num" || t.t === "str") def = t.v; + if (t.t === "num" || t.t === "bigint" || t.t === "date" || t.t === "str") def = t.v; else if (t.t === "id" && (t.v === "true" || t.v === "false")) def = t.v === "true"; else if (t.t === "id" && t.v === "null") def = null; + else if (t.t === "id" && t.v === "undefined") def = undefined; else throw new OmpTypeError(`unsupported default literal in "${this.#src}"`); hasDefault = true; } @@ -300,13 +388,36 @@ class StrParser { } parseUnion(): IR { - const first = this.parseBounded(); + const first = this.parseIntersection(); if (!this.#eatOp("|")) return first; - const members = [first, this.parseBounded()]; - while (this.#eatOp("|")) members.push(this.parseBounded()); + const members = [first, this.parseIntersection()]; + while (this.#eatOp("|")) members.push(this.parseIntersection()); return { k: "union", members }; } + parseIntersection(): IR { + const first = this.parseBounded(); + if (!this.#eatOp("&")) return first; + const members = [first, this.parseBounded()]; + while (this.#eatOp("&")) members.push(this.parseBounded()); + const literal = members.find((member): member is Extract => member.k === "lit"); + if (literal && typeof literal.v === "number") { + for (const member of members) { + if ( + member.k === "number" && + ((member.int && !Number.isInteger(literal.v)) || + (member.divisor !== undefined && literal.v % member.divisor !== 0) || + (member.min !== undefined && (member.xmin ? literal.v <= member.min : literal.v < member.min)) || + (member.max !== undefined && (member.xmax ? literal.v >= member.max : literal.v > member.max))) + ) { + throw new OmpTypeError("literal is excluded by intersection"); + } + } + return literal; + } + return { k: "intersection", members }; + } + /** * `NUM CMP base (CMP NUM)?` or `base (CMP NUM)?`, with `[]*` postfix on the * base AND after a trailing bound — `string>0[]` is an array of bounded @@ -315,30 +426,38 @@ class StrParser { parseBounded(): IR { const t = this.#peek(); const t1 = this.#peek(1); - if ((t?.t === "num" || t?.t === "date") && t1?.t === "op" && CMP[t1.v]) { + if ((t?.t === "num" || t?.t === "date") && t1?.t === "op" && (t1.v === "<" || t1.v === "<=")) { const lo = t.v; this.#pos += 2; let node = this.#eatDivisor(this.parsePostfix()); node = applyBound(node, flip(t1.v), lo, this.#src); const t2 = this.#peek(); - if (t2?.t === "op" && CMP[t2.v]) { - this.#pos++; - const hi = this.#next(); - if (hi.t !== "num" && hi.t !== "date") { - throw new OmpTypeError(`expected bound after comparator in "${this.#src}"`); - } - node = applyBound(node, t2.v, hi.v, this.#src); + if (!(t2?.t === "op" && CMP[t2.v])) { + throw new OmpTypeError(`left bound requires a corresponding right bound in "${this.#src}"`); } + if (t2.v === ">" || t2.v === ">=") { + throw new OmpTypeError(`right bound must use < or <= in "${this.#src}"`); + } + this.#pos++; + const hi = this.#next(); + if (hi.t !== "num" && hi.t !== "date") { + throw new OmpTypeError(`expected bound after comparator in "${this.#src}"`); + } + node = applyBound(node, t2.v, hi.v, this.#src); return this.#eatArraySuffixes(node); } let node = this.#eatDivisor(this.parsePostfix()); const t2 = this.#peek(); - if (t2?.t === "op" && CMP[t2.v]) { + if (t2?.t === "op" && t2.v === "==") { this.#pos++; const limit = this.#next(); - if (limit.t !== "num" && limit.t !== "date") { - throw new OmpTypeError(`expected bound after comparator in "${this.#src}"`); + if (limit.t !== "num" && limit.t !== "bigint" && limit.t !== "date") { + throw new OmpTypeError(`expected literal after == in "${this.#src}"`); } + node = applyEquality(node, limit.v, this.#src); + } else if (t2?.t === "op" && CMP[t2.v] && (this.#peek(1)?.t === "num" || this.#peek(1)?.t === "date")) { + this.#pos++; + const limit = this.#next() as Extract; node = applyBound(node, t2.v, limit.v, this.#src); node = this.#eatArraySuffixes(node); } @@ -350,10 +469,11 @@ class StrParser { const divisor = this.#next(); if (divisor.t !== "num") throw new OmpTypeError(`expected number after % in "${this.#src}"`); if (node.k !== "number") throw new OmpTypeError(`% requires number in "${this.#src}"`); - if (!Number.isFinite(divisor.v) || divisor.v === 0) - throw new OmpTypeError(`divisor must be non-zero in "${this.#src}"`); - node.divisor = divisor.v; - return node; + if (!Number.isFinite(divisor.v) || !Number.isInteger(divisor.v) || divisor.v === 0) + throw new OmpTypeError(`divisor must be a non-zero integer in "${this.#src}"`); + // Copy-on-write: the primary may be a shared node (string-def cache, + // generic arguments); stamping it in place would leak into other schemas. + return { ...node, divisor: Math.abs(divisor.v) }; } /** Wrap `node` in array IR for each `[]` pair at the cursor. */ @@ -371,14 +491,38 @@ class StrParser { let node = this.parsePrimary(); for (;;) { const t = this.#peek(); - if (!(t?.t === "op" && t.v === "[")) break; - this.#pos++; - if (!this.#eatOp("]")) throw new OmpTypeError(`expected ']' in "${this.#src}"`); - node = { k: "array", el: node }; + if (t?.t === "op" && t.v === "[") { + this.#pos++; + if (!this.#eatOp("]")) throw new OmpTypeError(`expected ']' in "${this.#src}"`); + node = { k: "array", el: node }; + continue; + } + if (t?.t === "op" && t.v === "#") { + this.#pos++; + const name = this.#next(); + if (name.t !== "id") throw new OmpTypeError(`expected brand name after # in "${this.#src}"`); + continue; + } + break; } return node; } + #parseGenericArguments(): IR[] { + this.#eatOp("<"); + const arguments_: IR[] = []; + if (this.#eatOp(">")) return arguments_; + for (;;) { + arguments_.push(this.parseUnion()); + if (this.#eatOp(">")) return arguments_; + if (!this.#eatOp(",")) throw new OmpTypeError(`expected ',' or '>' in "${this.#src}"`); + const next = this.#peek(); + if (next?.t === "op" && (next.v === "," || next.v === ">")) { + throw new OmpTypeError(`generic arguments cannot be empty in "${this.#src}"`); + } + } + } + parsePrimary(): IR { const t = this.#next(); if (t.t === "op" && t.v === "(") { @@ -386,10 +530,43 @@ class StrParser { if (!this.#eatOp(")")) throw new OmpTypeError(`expected ')' in "${this.#src}"`); return inner; } - if (t.t === "str" || t.t === "num" || t.t === "date") return { k: "lit", v: t.v }; + if (t.t === "str" || t.t === "num" || t.t === "bigint" || t.t === "date") return { k: "lit", v: t.v }; + if (t.t === "regex") return patternIR(t.v); if (t.t === "id") { + if (t.v === "keyof") { + try { + return keyOf(this.parsePostfix()); + } catch (error) { + if (error instanceof OmpTypeError) throw new OmpTypeError("keyof operand must be an object"); + throw error; + } + } + if (t.v === "Array.liftFrom" && this.#peek()?.t === "op" && this.#peek()?.v === "<") { + this.#pos++; + const element = this.parsePrimary(); + if (!this.#eatOp(">")) throw new OmpTypeError(`expected '>' in "${this.#src}"`); + const array: IR = { k: "array", el: element, desc: "an object" }; + return { + k: "morph", + input: { k: "union", members: [element, array] }, + fn: value => (Array.isArray(value) ? value : [value]), + out: array, + }; + } + if (t.v === "Record" && this.#peek()?.t === "op" && this.#peek()?.v === "<") { + const arguments_ = this.#parseGenericArguments(); + if (arguments_.length !== 2) throw new OmpTypeError("Record requires two arguments"); + return { k: "object", props: [], index: arguments_[1], extras: "keep" }; + } + if (this.#peek()?.t === "op" && this.#peek()?.v === "<" && this.#resolve?.hasGeneric?.(t.v)) { + const arguments_ = this.#parseGenericArguments(); + const instantiated = this.#resolve.generic?.(t.v, arguments_); + if (!instantiated) throw new OmpTypeError(`unknown generic "${t.v}" in "${this.#src}"`); + return instantiated; + } + const scoped = this.#resolve?.(t.v); const make = KEYWORDS[t.v]; - const keyword = make?.() ?? keywordIR(t.v) ?? this.#resolve?.(t.v); + const keyword = scoped ?? make?.() ?? keywordIR(t.v); if (!keyword) throw new OmpTypeError(`unknown keyword "${t.v}" in "${this.#src}"`); return keyword; } @@ -407,7 +584,7 @@ function isWhitespaceAt(src: string, index: number): boolean { /** Fast path for the literal unions pervasive in command schemas. */ function parseLiteralUnion(src: string): IR | undefined { - const members: IR[] = []; + const members: Extract[] = []; let index = 0; while (index < src.length && isWhitespaceAt(src, index)) index++; for (;;) { @@ -419,7 +596,19 @@ function parseLiteralUnion(src: string): IR | undefined { index = end + 1; while (index < src.length && isWhitespaceAt(src, index)) index++; if (index === src.length) { - return members.length === 1 ? members[0] : { k: "union", members }; + const ir: IR = members.length === 1 ? members[0] : { k: "union", members }; + let simple = true; + for (let member = 1; simple && member < members.length; member++) { + for (let previous = 0; previous < member; previous++) { + if (members[previous].k === "lit" && members[previous].v === members[member].v) { + simple = false; + break; + } + } + } + ir[kSimple] = simple; + ir[kSimpleOwner] = ir; + return ir; } if (src[index] !== "|") return undefined; index++; @@ -457,8 +646,8 @@ function genericArguments(src: string): { name: string; args: string[] } | undef return { name, args }; } -function genericKeys(ir: IR): Set { - const keys = new Set(); +function genericKeys(ir: IR): Set { + const keys = new Set(); const visit = (node: IR): void => { if (node.k === "lit" && typeof node.v === "string") keys.add(node.v); else if (node.k === "union") for (const member of node.members) visit(member); @@ -493,6 +682,11 @@ function mergeObjectIR(left: IR, right: IR): IR { k: "object", props, index: right.index ?? left.index, + symbolIndex: right.symbolIndex ?? left.symbolIndex, + patternIndexes: + left.patternIndexes === undefined && right.patternIndexes === undefined + ? undefined + : [...(left.patternIndexes ?? []), ...(right.patternIndexes ?? [])], extras: right.extras === "keep" ? left.extras : right.extras, }; } @@ -500,9 +694,26 @@ function mergeObjectIR(left: IR, right: IR): IR { function parseGeneric(src: string, resolve?: AliasResolver): IR | undefined { const generic = genericArguments(src); if (generic === undefined) return undefined; + if (generic.name === "Array.liftFrom" && generic.args.length === 1) { + const element = parseDef(generic.args[0], resolve); + const array: IR = { k: "array", el: element, desc: "an object" }; + return { + k: "morph", + input: { k: "union", members: [element, array] }, + fn: value => (Array.isArray(value) ? value : [value]), + out: array, + }; + } if (generic.name === "Record" && generic.args.length === 2) { return { k: "object", props: [], index: parseDef(generic.args[1], resolve), extras: "keep" }; } + if ((generic.name === "Extract" || generic.name === "Exclude") && generic.args.length === 2) { + return distributeFilter( + parseDef(generic.args[0], resolve), + parseDef(generic.args[1], resolve), + generic.name === "Extract", + ); + } if ((generic.name === "Partial" || generic.name === "Required") && generic.args.length === 1) { const object = resolveStructuralIR(parseDef(generic.args[0], resolve)); if (object.k !== "object") throw new OmpTypeError(`${generic.name} requires an object`); @@ -522,78 +733,88 @@ function parseGeneric(src: string, resolve?: AliasResolver): IR | undefined { return undefined; } -function dateLiteral(source: string): Date { - const value = new Date(source); - if (!Number.isFinite(value.valueOf())) throw new OmpTypeError(`invalid Date literal "${source}"`); - return value; +/** + * Subtype comparison lives in `type.ts` (it needs full traversal), so it is + * installed here at module load for the parser's `Extract`/`Exclude` support. + */ +let isAssignable: (source: IR, target: IR) => boolean = () => false; + +/** Install the assignability comparator used by `Extract`/`Exclude`. */ +export function useAssignability(compare: (source: IR, target: IR) => boolean): void { + isAssignable = compare; } -function parseDateExpression(src: string): IR | undefined { - const first = src.charCodeAt(0); - if (first !== 68 && first !== 100) return undefined; - const literal = src.match(/^d(['"])(.*)\1$/); - if (literal) return { k: "lit", v: dateLiteral(literal[2]) }; - const forward = src.match(/^Date\s*(<=|<|>=|>)\s*d(['"])(.*)\2$/); - const reverse = src.match(/^d(['"])(.*)\1\s*(<=|<|>=|>)\s*Date$/); - if (!forward && !reverse) return undefined; - const bound = dateLiteral(forward ? forward[3] : (reverse?.[2] ?? "")); - const operator = forward - ? forward[1] - : reverse?.[3] === "<=" - ? ">=" - : reverse?.[3] === "<" - ? ">" - : reverse?.[3] === ">=" - ? "<=" - : "<"; - const timestamp = bound.valueOf(); - const relation = - operator === ">=" - ? "at or after" - : operator === ">" - ? "later than" - : operator === "<=" - ? "at or before" - : "earlier than"; - return { - k: "refine", - base: { k: "instance", ctor: Date, expected: "a Date" }, - pred: value => { - if (!(value instanceof Date)) return false; - const actual = value.valueOf(); - return operator === ">=" - ? actual >= timestamp - : operator === ">" - ? actual > timestamp - : operator === "<=" - ? actual <= timestamp - : actual < timestamp; - }, - expected: `a Date ${relation} ${bound.toISOString()}`, - json: operator === ">=" || operator === ">" ? { minimum: bound.toISOString() } : { maximum: bound.toISOString() }, - }; +/** + * Distribute `base` over its union members, keeping those assignable to + * `target` (`keepAssignable`) or those that are not (`Exclude`). + */ +export function distributeFilter(base: IR, target: IR, keepAssignable: boolean): IR { + const resolved = base.k === "alias" ? base.resolve() : base; + const members = resolved.k === "union" ? resolved.members : [resolved]; + const retained = members.filter(member => isAssignable(member, target) === keepAssignable); + if (retained.length === 0) return { k: "never" }; + return retained.length === 1 ? retained[0] : { k: "union", members: retained }; } /** Parse recurring global DSL fragments once; scoped aliases bypass the cache. */ +function parseRegexExec(src: string): IR | undefined { + if (!src.startsWith("x/")) return undefined; + const end = src.lastIndexOf("/"); + if (end < 2) throw new OmpTypeError(`unterminated regular expression in "${src}"`); + let regex: RegExp; + try { + regex = new RegExp(src.slice(2, end), src.slice(end + 1)); + } catch { + throw new OmpTypeError(`invalid regular expression "${src.slice(1)}"`); + } + return { + k: "morph", + input: patternIR(regex), + fn: (value, context) => { + regex.lastIndex = 0; + return regex.exec(value as string) ?? context.error(`a string matching ${regex}`); + }, + }; +} + function parseStringDef(src: string, resolve?: AliasResolver): ParsedTop { - if (resolve === undefined) { + const cacheable = resolve === undefined || (!src.includes("this") && THIS_ONLY_RESOLVERS.has(resolve)); + if (cacheable) { const cached = stringDefCache.get(src); if (cached) return cached; } - let ir = parseDateExpression(src) ?? parseLiteralUnion(src) ?? parseGeneric(src, resolve); + const pipeIndex = src.indexOf("|>"); + if (pipeIndex >= 0) { + const input = src.slice(0, pipeIndex).trim(); + const output = src.slice(pipeIndex + 2).trim(); + if (input.length === 0 || output.length === 0) { + throw new OmpTypeError(`pipe expression requires operands in "${src}"`); + } + const parsedInput = parseStringDef(input, resolve); + if (parsedInput.hasDefault) { + throw new OmpTypeError(`unexpected pipe expression after default in "${src}"`); + } + const parsed: ParsedTop = { + ir: { + k: "morph", + input: parsedInput.ir, + fn: value => value, + out: parseStringDef(output, resolve).ir, + }, + hasDefault: false, + optional: false, + }; + if (cacheable && stringDefCache.size < STRING_DEF_CACHE_MAX) stringDefCache.set(src, parsed); + return parsed; + } + let ir = parseLiteralUnion(src) ?? parseRegexExec(src) ?? parseGeneric(src, resolve); if (ir === undefined && src.startsWith("`") && src.endsWith("`")) { ir = templateIR(src.slice(1, -1)); - } else if (ir === undefined && src.startsWith("/") && src.lastIndexOf("/") > 0) { - const end = src.lastIndexOf("/"); - try { - ir = patternIR(new RegExp(src.slice(1, end), src.slice(end + 1))); - } catch { - throw new OmpTypeError(`invalid regular expression "${src}"`); - } } + const parsed: ParsedTop = ir === undefined ? new StrParser(src, resolve).parseTop() : { ir, hasDefault: false, optional: false }; - if (resolve === undefined && stringDefCache.size < STRING_DEF_CACHE_MAX) stringDefCache.set(src, parsed); + if (cacheable && stringDefCache.size < STRING_DEF_CACHE_MAX) stringDefCache.set(src, parsed); return parsed; } @@ -610,8 +831,43 @@ function flip(op: string): string { } } -/** Apply `node CMP value` — numeric/string/array ranges or Date bounds. */ +function applyEquality(node: IR, value: number | bigint | Date, src: string): IR { + if (node.k === "union") { + return { k: "union", members: node.members.map(member => applyEquality(member, value, src)) }; + } + if (value instanceof Date) { + if (!acceptsDate(node)) throw new OmpTypeError(`Date equality requires Date in "${src}"`); + return { k: "lit", v: value }; + } + if (node.k === "number" && typeof value === "number") return { k: "lit", v: value }; + if (node.k === "bigint" && typeof value === "bigint") return { k: "lit", v: value }; + if ((node.k === "string" || node.k === "array") && typeof value === "number") { + if (!Number.isInteger(value) || value < 0) { + throw new OmpTypeError(`exact length must be a non-negative integer in "${src}"`); + } + return { ...node, min: value, max: value }; + } + throw new OmpTypeError(`equality literal is incompatible with ${node.k} in "${src}"`); +} + +/** + * Apply `node CMP value` — numeric/string/array ranges or Date bounds. + * Copy-on-write: `node` may be shared (string-def cache, generic arguments, + * resolved aliases), so bounds land on a fresh node, never in place. + */ function applyBound(node: IR, op: string, value: number | Date, src: string): IR { + if (node.k === "alias") return applyBound(node.resolve(), op, value, src); + if (node.k === "refine" && !(value instanceof Date)) { + return { ...node, base: applyBound(node.base, op, value, src) }; + } + if (node.k === "union") { + const kinds = new Set(node.members.map(boundKind)); + if (kinds.size !== 1) throw new OmpTypeError(`cannot apply one bound to multiple bound kinds in "${src}"`); + return { k: "union", members: node.members.map(member => applyBound(member, op, value, src)) }; + } + if (!(value instanceof Date) && acceptsDate(node)) { + return applyBound(node, op, new Date(value), src); + } if (value instanceof Date) { if (!acceptsDate(node)) throw new OmpTypeError(`date bound requires Date in "${src}"`); const limit = value.valueOf(); @@ -629,42 +885,60 @@ function applyBound(node: IR, op: string, value: number | Date, src: string): IR }; } if (node.k === "number") { + const bounded = { ...node }; switch (op) { case ">=": - node.min = value; - node.xmin = false; + bounded.min = value; + bounded.xmin = false; break; case ">": - node.min = value; - node.xmin = true; + bounded.min = value; + bounded.xmin = true; break; case "<=": - node.max = value; - node.xmax = false; + bounded.max = value; + bounded.xmax = false; break; case "<": - node.max = value; - node.xmax = true; + bounded.max = value; + bounded.xmax = true; break; } - return node; + if ( + bounded.min !== undefined && + bounded.max !== undefined && + (bounded.min > bounded.max || (bounded.min === bounded.max && (bounded.xmin || bounded.xmax))) + ) { + throw new OmpTypeError(`numeric range is unsatisfiable in "${src}"`); + } + return bounded; } if (node.k === "string" || node.k === "array") { + if (!Number.isInteger(value) || value < 0) { + throw new OmpTypeError(`length bound must be a non-negative integer in "${src}"`); + } + const bounded = { ...node }; switch (op) { case ">=": - node.min = value; + bounded.min = value; break; case ">": - node.min = value + 1; + bounded.min = value + 1; break; case "<=": - node.max = value; + bounded.max = value; break; case "<": - node.max = value - 1; + bounded.max = value - 1; break; } - return node; + if ( + (bounded.min !== undefined && bounded.max !== undefined && bounded.min > bounded.max) || + (bounded.max ?? 0) < 0 + ) { + throw new OmpTypeError(`length range is unsatisfiable in "${src}"`); + } + return bounded; } throw new OmpTypeError(`cannot bound ${node.k} in "${src}"`); } @@ -681,12 +955,18 @@ function isEmbedded(def: unknown): def is EmbeddableSchema { /** Embed a schema value: inline pure structure, keep `sub` nodes for stepped schemas. */ export function embed(schema: EmbeddableSchema): IR { - if (schema.hasSteps) return { k: "sub", schema, desc: schema.description }; + if (schema.hasSteps) return { k: "sub", schema, desc: schema.description, descAuto: schema.ir.desc === undefined }; if (schema.description !== undefined && schema.ir.desc === undefined) { - return { ...schema.ir, desc: schema.description }; + return { ...schema.ir, desc: schema.description, descAuto: true }; } return schema.ir; } +function boundKind(node: IR): "number" | "length" | "date" | undefined { + if (node.k === "number") return "number"; + if (node.k === "string" || node.k === "array") return "length"; + if (acceptsDate(node)) return "date"; + return undefined; +} function isCallback(value: unknown): value is (input: unknown, context: MorphContext) => unknown { return typeof value === "function"; @@ -709,52 +989,137 @@ function parseTupleItem(def: unknown, resolve?: AliasResolver): TupleItemIR { hasDefault: true, }; } + if (typeof def === "string") { + const parsed = parseStringDef(def, resolve); + return { + val: parsed.ir, + opt: parsed.optional || parsed.hasDefault, + def: parsed.def, + hasDefault: parsed.hasDefault, + }; + } return { val: parseDef(def, resolve), opt: false }; } -function parseTuple(def: readonly unknown[], resolve?: AliasResolver): TupleIR { - const prefix: TupleItemIR[] = []; - const postfix: IR[] = []; - let variadic: IR | undefined; - let optionalSeen = false; +function cloneTuple(tuple: TupleIR): TupleIR { + return { + ...tuple, + prefix: tuple.prefix.map(item => ({ ...item })), + postfix: [...tuple.postfix], + }; +} + +function hasOptionalPrefix(tuple: TupleIR): boolean { + return tuple.prefix.some(item => item.opt || item.hasDefault === true); +} + +function appendTupleItem(tuple: TupleIR, item: TupleItemIR): void { + if (tuple.variadic !== undefined) { + if (item.opt || item.hasDefault) { + throw new OmpTypeError("An optional element may not follow a variadic element"); + } + if (hasOptionalPrefix(tuple)) { + throw new OmpTypeError("A postfix required element cannot follow an optional or defaultable element"); + } + tuple.postfix.push(item.val); + return; + } + if (item.hasDefault && tuple.prefix.some(prefixItem => prefixItem.opt && !prefixItem.hasDefault)) { + throw new OmpTypeError("A defaultable element may not follow an optional element without a default"); + } + if (hasOptionalPrefix(tuple) && !item.opt) { + throw new OmpTypeError("required tuple elements cannot follow optional elements"); + } + tuple.prefix.push(item); +} + +function appendTuple(target: TupleIR, spread: TupleIR): void { + if (target.variadic !== undefined && spread.variadic !== undefined) { + throw new OmpTypeError("a tuple may have one spread followed by an array definition"); + } + for (const item of spread.prefix) appendTupleItem(target, { ...item }); + if (spread.variadic !== undefined) { + target.variadic = spread.variadic; + } + for (const item of spread.postfix) appendTupleItem(target, { val: item, opt: false }); +} + +function spreadAlternatives(spread: IR): TupleIR[] { + if (spread.k === "alias") return spreadAlternatives(spread.resolve()); + if (spread.k === "sub") return spreadAlternatives(spread.schema.ir); + if (spread.k === "union") return spread.members.flatMap(spreadAlternatives); + if (spread.k === "array") return [{ k: "tuple", prefix: [], variadic: spread.el, postfix: [] }]; + if (spread.k === "tuple") return [spread]; + throw new OmpTypeError("tuple spread element must be an array"); +} + +function parseTuple(def: readonly unknown[], resolve?: AliasResolver): IR { + let branches: TupleIR[] = [{ k: "tuple", prefix: [], postfix: [] }]; for (let index = 0; index < def.length; index++) { if (def[index] === "...") { - if (variadic !== undefined || index + 1 >= def.length) { + if (index + 1 >= def.length) { throw new OmpTypeError("a tuple may have one spread followed by an array definition"); } - const spread = parseDef(def[++index], resolve); - if (spread.k !== "array") throw new OmpTypeError("tuple spread element must be an array"); - variadic = spread.el; - continue; - } - if (variadic !== undefined) { - const item = parseTupleItem(def[index], resolve); - if (item.opt || item.hasDefault) { - throw new OmpTypeError("optional tuple elements cannot follow a variadic element"); + const alternatives = spreadAlternatives(parseDef(def[++index], resolve)); + const distributed: TupleIR[] = []; + for (const branch of branches) { + for (const alternative of alternatives) { + const next = cloneTuple(branch); + appendTuple(next, alternative); + distributed.push(next); + } } - postfix.push(item.val); + branches = distributed; continue; } const item = parseTupleItem(def[index], resolve); - if (optionalSeen && !item.opt && !item.hasDefault) { - throw new OmpTypeError("required tuple elements cannot follow optional elements"); - } - optionalSeen ||= item.opt || item.hasDefault === true; - prefix.push(item); + for (const branch of branches) appendTupleItem(branch, { ...item }); } - return { k: "tuple", prefix, variadic, postfix }; + return branches.length === 1 ? branches[0] : { k: "union", members: branches }; } /** Build the runtime schema for an object's or tuple's keys. */ export function keyOf(node: IR): IR { + if (node.k === "alias") return keyOf(node.resolve()); + if (node.k === "sub") return keyOf(node.schema.ir); + if (node.k === "refine") return keyOf(node.base); + if (node.k === "intersection") { + const members = node.members.flatMap(member => { + const keys = keyOf(member); + return keys.k === "union" ? keys.members : [keys]; + }); + return members.length === 1 ? members[0] : { k: "union", members }; + } if (node.k === "object") { const members: IR[] = node.props.map(prop => ({ k: "lit", v: prop.key })); - if (node.index !== undefined) members.push({ k: "string" }); + if (node.index !== undefined || (node.patternIndexes?.length ?? 0) > 0) members.push({ k: "string" }); + if (node.symbolIndex !== undefined) members.push({ k: "symbol" }); if (members.length === 0) return { k: "never" }; return members.length === 1 ? members[0] : { k: "union", members }; } if (node.k === "tuple") return { k: "number", int: true, min: 0 }; - throw new OmpTypeError(`keyof requires an object or tuple (was ${node.k})`); + if (node.k === "union") { + if (node.members.length === 0) return { k: "never" }; + const literalSets = node.members.map(member => { + const keyed = keyOf(member); + const literals = keyed.k === "union" ? keyed.members : [keyed]; + const keys = new Set(); + for (const literal of literals) { + if ( + literal.k === "lit" && + (typeof literal.v === "string" || typeof literal.v === "number" || typeof literal.v === "symbol") + ) { + keys.add(literal.v); + } + } + return keys; + }); + const common = [...literalSets[0]].filter(key => literalSets.slice(1).every(keys => keys.has(key))); + if (common.length === 0) throw new OmpTypeError("keyof operand must be an object"); + const members = common.map(value => ({ k: "lit", v: value }) satisfies IR); + return members.length === 1 ? members[0] : { k: "union", members }; + } + throw new OmpTypeError("keyof operand must be an object"); } function parseArrayExpression(def: readonly unknown[], resolve?: AliasResolver): IR { @@ -765,7 +1130,11 @@ function parseArrayExpression(def: readonly unknown[], resolve?: AliasResolver): for (let index = 1; index < def.length; index++) { const ctor = def[index]; if (!isConstructor(ctor)) throw new OmpTypeError("instanceof operands must be constructors"); - members.push({ k: "instance", ctor, expected: `an instance of ${ctor.name || "the constructor"}` }); + members.push({ + k: "instance", + ctor, + expected: ctor === Error ? "an Error" : `an instance of ${ctor.name || "the constructor"}`, + }); } return members.length === 1 ? members[0] : { k: "union", members }; } @@ -790,30 +1159,59 @@ function parseArrayExpression(def: readonly unknown[], resolve?: AliasResolver): if (def.length === 3 && def[1] === ":") { if (!isCallback(def[2])) throw new OmpTypeError("narrow operator requires a predicate"); const predicate = def[2]; + const name = predicate.name; + const expected = name.length === 0 ? "valid according to an anonymous predicate" : `valid according to ${name}`; return { k: "refine", base: parseDef(def[0], resolve), - pred: value => - predicate(value, { - error: () => OmpErrors.single([], "the predicate", value), - reject: () => OmpErrors.single([], "the predicate", value), - }) === true, - expected: "a value satisfying the predicate", + pred: value => { + let errors: OmpErrors | undefined; + const error = ( + input: + | string + | { + expected: string; + actual?: unknown; + path?: readonly PropertyKey[]; + relativePath?: readonly PropertyKey[]; + }, + ): OmpErrors => { + const detail = typeof input === "string" ? { expected: input } : input; + const next = OmpErrors.single([...(detail.path ?? detail.relativePath ?? [])], detail.expected, value, { + preserveActual: true, + ...(Object.hasOwn(detail, "actual") ? { actual: String(detail.actual) } : {}), + }); + if (errors) errors.append(next); + else errors = next; + return next; + }; + const result = predicate(value, { error, reject: error }); + return errors ?? (result instanceof OmpErrors ? result : result === true); + }, + expected, }; } if (def.length >= 3 && def[1] === "@") { const base = parseDef(def[0], resolve); const meta = def[2]; - if (typeof meta === "string") return { ...base, desc: meta }; - if (typeof meta === "object" && meta !== null && "description" in meta && typeof meta.description === "string") { - return { ...base, desc: meta.description }; + if (typeof meta === "string") return { ...base, desc: meta, cfg: { ...base.cfg, expected: meta } }; + if (typeof meta === "object" && meta !== null) { + const config = meta as ErrorConfig & { description?: string }; + return { + ...base, + cfg: { + ...(typeof config.description === "string" ? { expected: config.description } : {}), + ...config, + }, + ...(typeof config.description === "string" ? { desc: config.description } : {}), + }; } return base; } return parseTuple(def, resolve); } -function isObjectDefinition(def: unknown): def is Record { +function isObjectDefinition(def: unknown): def is Record { return ( typeof def === "object" && def !== null && @@ -823,141 +1221,508 @@ function isObjectDefinition(def: unknown): def is Record { ); } -function parseObjectDefinition(def: Record, resolve?: AliasResolver): IR { +function spreadObjectOf(ir: IR): Extract | undefined { + if (ir.k === "alias") return spreadObjectOf(ir.resolve()); + if (ir.k === "sub") return spreadObjectOf(ir.schema.ir); + if (ir.k === "refine") return spreadObjectOf(ir.base); + if (ir.k === "anyobject") return { k: "object", props: [], extras: "keep" }; + if (ir.k === "object") return ir; + if (ir.k !== "intersection") return undefined; + let result: Extract = { k: "object", props: [], extras: "keep" }; + for (const member of ir.members) { + const object = spreadObjectOf(member); + if (object === undefined) return undefined; + result = mergeObjectIR(result, object) as Extract; + } + return result; +} + +function indexKeyKind( + key: IR, + value: IR, + props: PropIR[], + indexes: { + string?: IR; + symbol?: IR; + patterns: { key: IR; val: IR }[]; + }, +): void { + if (key.k === "alias") return indexKeyKind(key.resolve(), value, props, indexes); + if (key.k === "union") { + for (const member of key.members) indexKeyKind(member, value, props, indexes); + return; + } + if (key.k === "lit" && (typeof key.v === "string" || typeof key.v === "symbol")) { + props.push({ key: key.v, opt: false, val: value }); + return; + } + if (key.k === "string") { + indexes.string = value; + return; + } + if (key.k === "symbol") { + indexes.symbol = value; + return; + } + if (key.k === "refine" && key.base.k === "string") { + indexes.patterns.push({ key, val: value }); + return; + } + throw new OmpTypeError(`indexed key definition must resolve to a string or symbol (was ${expectedOf(key)})`); +} + +function addObjectProp(props: PropIR[], spreadKeys: Set | undefined, prop: PropIR): void { + if (spreadKeys === undefined) { + props.push(prop); + return; + } + const previous = props.findIndex(candidate => candidate.key === prop.key); + if (previous < 0) { + props.push(prop); + return; + } + if (!spreadKeys.delete(prop.key)) throw new OmpTypeError(`duplicate object key ${String(prop.key)}`); + props[previous] = prop; +} + +function parseObjectDefinition(def: Record, resolve?: AliasResolver): IR { const props: PropIR[] = []; - let index: IR | undefined; + let spreadKeys: Set | undefined; + let normalizedKey: PropertyKey | undefined; + let normalizedKeys: PropertyKey[] | undefined; + let indexes: + | { + string?: IR; + symbol?: IR; + patterns: { key: IR; val: IR }[]; + } + | undefined; let extras: Extras = "keep"; - for (const rawKey in def) { - const val = def[rawKey]; - if (rawKey === "+") { - if (val === "reject" || val === "delete") extras = val; - else if (val === "ignore") extras = "keep"; + let simple = true; + for (const originalKey in def) { + if (!Object.hasOwn(def, originalKey)) continue; + const val = def[originalKey]; + if (originalKey === "+") { + if (val === "reject" || val === "delete") { + extras = val; + if (val === "delete") simple = false; + } else if (val === "ignore") extras = "keep"; else throw new OmpTypeError(`bad "+" value ${String(val)}`); continue; } - if (rawKey === "...") { - const spread = parseDef(val, resolve); - if (spread.k !== "object") throw new OmpTypeError("object spread must resolve to an object"); + if (originalKey === "...") { + const parsed = parseDef(val, resolve); + const spread = spreadObjectOf(parsed); + if (spread === undefined) { + throw new OmpTypeError(`object spread must resolve to an object literal (was ${expectedOf(parsed)})`); + } + if (simple && !isSimpleIR(spread)) simple = false; + spreadKeys ??= new Set(); for (const prop of spread.props) { const previous = props.findIndex(candidate => candidate.key === prop.key); if (previous < 0) props.push(prop); else props[previous] = prop; + spreadKeys.add(prop.key); + } + if (spread.index !== undefined || spread.symbolIndex !== undefined || spread.patternIndexes !== undefined) { + const objectIndexes = indexes ?? { patterns: [] }; + indexes = objectIndexes; + objectIndexes.string ??= spread.index; + objectIndexes.symbol ??= spread.symbolIndex; + if (spread.patternIndexes !== undefined) objectIndexes.patterns.push(...spread.patternIndexes); } - index ??= spread.index; if (spread.extras !== "keep") extras = spread.extras; continue; } - if (rawKey === "[string]") { - index = parseDef(val, resolve); + if (typeof originalKey === "string" && originalKey.startsWith("[") && originalKey.endsWith("]")) { + let value: IR; + if (typeof val === "string") { + const parsed = parseStringDef(val, resolve); + if (parsed.hasDefault) throw new OmpTypeError("index signatures cannot specify a default"); + value = parsed.ir; + } else { + if (Array.isArray(val) && val.length === 3 && val[1] === "=") { + throw new OmpTypeError("index signatures cannot specify a default"); + } + value = parseDef(val, resolve); + if (isEmbedded(val) && val.hasDefault) { + throw new OmpTypeError("index signatures cannot specify a default"); + } + } + const keyDefinition = originalKey.slice(1, -1); + const regex = /^\/((?:\\.|[^\\/])*)\/([dgimsuvy]*)$/.exec(keyDefinition); + let key: IR; + if (regex === null) { + key = parseDef(keyDefinition, resolve); + } else { + try { + key = patternIR(new RegExp(regex[1], regex[2])); + } catch { + throw new OmpTypeError(`invalid index signature pattern ${keyDefinition}`); + } + } + const objectIndexes = indexes ?? { patterns: [] }; + indexes = objectIndexes; + indexKeyKind(key, value, props, objectIndexes); + if (simple && (!isSimpleIR(key) || !isSimpleIR(value))) simple = false; continue; } - const opt = rawKey.charCodeAt(rawKey.length - 1) === 63; + const escapedOptional = typeof originalKey === "string" && originalKey.endsWith("\\?"); + const escapedMeta = + typeof originalKey === "string" && + (originalKey === "\\+" || originalKey === "\\..." || originalKey.startsWith("\\[")); + const rawKey = escapedOptional + ? `${originalKey.slice(0, -2)}?` + : escapedMeta + ? originalKey.slice(1) + : originalKey; + const opt = typeof rawKey === "string" && !escapedOptional && !escapedMeta && rawKey.endsWith("?"); const key = opt ? rawKey.slice(0, -1) : rawKey; + let prop: PropIR; if (typeof val === "string") { const parsed = parseStringDef(val, resolve); - const prop: PropIR = { key, opt: opt || parsed.optional, val: parsed.ir }; + prop = { key, opt: opt || parsed.optional, val: parsed.ir }; if (parsed.hasDefault) { prop.def = parsed.def; prop.hasDefault = true; } - props.push(prop); } else if (Array.isArray(val) && val.length === 2 && val[1] === "?") { - props.push({ key, opt: true, val: parseDef(val[0], resolve) }); + prop = { key, opt: true, val: parseDef(val[0], resolve) }; } else if (Array.isArray(val) && val.length === 3 && val[1] === "=") { - props.push({ + prop = { key, opt, val: parseDef(val[0], resolve), def: val[2], defFactory: typeof val[2] === "function", hasDefault: true, - }); + }; } else if (isEmbedded(val)) { - if (val.hasDefault) { - props.push({ - key, - opt, - val: embed(val), - def: val.defaultValue, - defFactory: typeof val.defaultValue === "function", - hasDefault: true, - }); - } else { - props.push({ key, opt, val: embed(val) }); - } - } else if (isObjectDefinition(val)) { - props.push({ key, opt, val: parseObjectDefinition(val, resolve) }); + prop = val.hasDefault + ? { + key, + opt, + val: embed(val), + def: val.hasDefaultOutput ? val.defaultOutput : val.defaultValue, + defFactory: typeof val.defaultValue === "function", + hasDefault: true, + defValidated: val.hasDefaultOutput, + } + : { key, opt, val: embed(val) }; } else { - props.push({ key, opt, val: parseDef(val, resolve) }); + prop = { + key, + opt, + val: isObjectDefinition(val) ? parseObjectDefinition(val, resolve) : parseDef(val, resolve), + }; } + if (key !== originalKey) { + if (!spreadKeys?.has(key) && props.some(candidate => candidate.key === key)) { + throw new OmpTypeError(`duplicate object key ${String(key)}`); + } + if (normalizedKey === undefined) normalizedKey = key; + else { + normalizedKeys ??= [normalizedKey]; + normalizedKeys.push(key); + } + } else if (!spreadKeys?.has(key) && (key === normalizedKey || normalizedKeys?.includes(key))) { + throw new OmpTypeError(`duplicate object key ${String(key)}`); + } + if (opt && prop.hasDefault) throw new OmpTypeError(`optional key ${String(key)} cannot specify a default`); + if (simple && (prop.hasDefault || !isSimpleIR(prop.val))) simple = false; + addObjectProp(props, spreadKeys, prop); } - return { k: "object", props, index, extras }; + for (const key of Object.getOwnPropertySymbols(def)) { + if (!Object.prototype.propertyIsEnumerable.call(def, key)) continue; + const val = def[key]; + let prop: PropIR; + if (typeof val === "string") { + const parsed = parseStringDef(val, resolve); + prop = { key, opt: parsed.optional, val: parsed.ir }; + if (parsed.hasDefault) { + prop.def = parsed.def; + prop.hasDefault = true; + } + } else if (Array.isArray(val) && val.length === 2 && val[1] === "?") { + prop = { key, opt: true, val: parseDef(val[0], resolve) }; + } else if (Array.isArray(val) && val.length === 3 && val[1] === "=") { + prop = { + key, + opt: false, + val: parseDef(val[0], resolve), + def: val[2], + defFactory: typeof val[2] === "function", + hasDefault: true, + }; + } else if (isEmbedded(val)) { + prop = val.hasDefault + ? { + key, + opt: false, + val: embed(val), + def: val.hasDefaultOutput ? val.defaultOutput : val.defaultValue, + defFactory: typeof val.defaultValue === "function", + hasDefault: true, + defValidated: val.hasDefaultOutput, + } + : { key, opt: false, val: embed(val) }; + } else { + prop = { + key, + opt: false, + val: isObjectDefinition(val) ? parseObjectDefinition(val, resolve) : parseDef(val, resolve), + }; + } + if (simple && (prop.hasDefault || !isSimpleIR(prop.val))) simple = false; + addObjectProp(props, spreadKeys, prop); + } + const object: IR = { + k: "object", + props, + index: indexes?.string, + symbolIndex: indexes?.symbol, + patternIndexes: indexes === undefined || indexes.patterns.length === 0 ? undefined : indexes.patterns, + extras, + }; + object[kSimple] = simple; + object[kSimpleOwner] = object; + return object; } /** Parse a definition, optionally resolving names from an enclosing scope. */ export function parseDef(def: unknown, resolve?: AliasResolver): IR { if (typeof def === "string") { const parsed = parseStringDef(def, resolve); + if (parsed.hasDefault) { + throw new OmpTypeError("A default may only be specified for an object property or tuple element"); + } if (parsed.optional) { throw new OmpTypeError(`optional "?" marker is only valid on object property values`); } return parsed.ir; } - if (Array.isArray(def)) return parseArrayExpression(def, resolve); + if (Array.isArray(def)) { + if (def.length === 3 && def[1] === "=") { + throw new OmpTypeError("A default may only be specified for an object property or tuple element"); + } + return parseArrayExpression(def, resolve); + } if (def instanceof RegExp) return patternIR(def); if (def instanceof Date) return { k: "lit", v: def }; if (isEmbedded(def)) return embed(def); + if (typeof def === "function") { + const resolved = Reflect.apply(def, undefined, []); + if (!isEmbedded(resolved)) { + throw new OmpTypeError(`thunk must return a Type (was ${typeof resolved})`); + } + return embed(resolved); + } if (isObjectDefinition(def)) return parseObjectDefinition(def, resolve); - throw new OmpTypeError(`unsupported definition ${String(def)}`); + throw new OmpTypeError(`unsupported definition ${String(def)} (was ${typeof def})`); +} + +/** Whether `ir` needs no construction-time normalization or morph analysis. */ +export function isSimpleIR(ir: IR): boolean { + const cached = ir[kSimpleOwner] === ir ? ir[kSimple] : undefined; + if (cached !== undefined) return cached; + const simple = scanSimpleIR(ir); + ir[kSimple] = simple; + ir[kSimpleOwner] = ir; + return simple; +} + +function scanSimpleIR(ir: IR): boolean { + switch (ir.k) { + case "intersection": + case "morph": + case "sub": + case "alias": + return false; + case "refine": + return scanSimpleIR(ir.base); + case "union": + if (ir.members.length < 2) return false; + if ( + ir.members.length === 2 && + ir.members.every(member => member.k === "lit" && typeof member.v === "boolean") + ) { + return false; + } + for (let index = 0; index < ir.members.length; index++) { + const member = ir.members[index]; + if ( + member.k !== "lit" || + (member.v !== null && (typeof member.v === "object" || typeof member.v === "function")) + ) { + return false; + } + for (let previous = 0; previous < index; previous++) { + const candidate = ir.members[previous]; + if (candidate.k === "lit" && candidate.v === member.v) return false; + } + } + return true; + case "array": + return scanSimpleIR(ir.el); + case "tuple": + for (const item of ir.prefix) { + if (item.hasDefault || !scanSimpleIR(item.val)) return false; + } + if (ir.variadic !== undefined && !scanSimpleIR(ir.variadic)) return false; + for (const item of ir.postfix) if (!scanSimpleIR(item)) return false; + return true; + case "object": + if (ir.extras === "delete") return false; + for (const prop of ir.props) { + if (prop.hasDefault || !scanSimpleIR(prop.val)) return false; + } + if (ir.index !== undefined && !scanSimpleIR(ir.index)) return false; + if (ir.symbolIndex !== undefined && !scanSimpleIR(ir.symbolIndex)) return false; + if (ir.patternIndexes !== undefined) { + for (const pattern of ir.patternIndexes) { + if (!scanSimpleIR(pattern.key) || !scanSimpleIR(pattern.val)) return false; + } + } + return true; + default: + return true; + } } /** True when validating `ir` can produce an output different from its input. */ export function hasMorph(ir: IR): boolean { - const cached = ir[kMorph]; + const cached = ir[kMorphOwner] === ir ? ir[kMorph] : undefined; + if (cached !== undefined) return cached; + const result = scanMorph(ir); + ir[kMorph] = result; + ir[kMorphOwner] = ir; + return result; +} + +function scanMorph(ir: IR, activeAliases?: Set): boolean { + const cached = ir[kMorphOwner] === ir ? ir[kMorph] : undefined; if (cached !== undefined) return cached; let result = false; switch (ir.k) { case "sub": - result = true; - break; case "morph": - case "alias": result = true; break; + case "alias": { + if (activeAliases?.has(ir)) return false; + const aliases = activeAliases ?? new Set(); + aliases.add(ir); + result = scanMorph(ir.resolve(), aliases); + aliases.delete(ir); + return result; + } case "object": result = ir.extras === "delete"; - for (let i = 0; !result && i < ir.props.length; i++) { - const prop = ir.props[i]; - result = prop.hasDefault === true || hasMorph(prop.val); + for (let index = 0; !result && index < ir.props.length; index++) { + const prop = ir.props[index]; + result = prop.hasDefault === true || scanMorph(prop.val, activeAliases); + } + if (!result && ir.index !== undefined) result = scanMorph(ir.index, activeAliases); + if (!result && ir.symbolIndex !== undefined) result = scanMorph(ir.symbolIndex, activeAliases); + if (!result && ir.patternIndexes !== undefined) { + for (const pattern of ir.patternIndexes) { + if (scanMorph(pattern.val, activeAliases)) { + result = true; + break; + } + } } - if (!result && ir.index !== undefined) result = hasMorph(ir.index); break; case "array": - result = hasMorph(ir.el); + result = scanMorph(ir.el, activeAliases); break; case "union": - result = ir.members.some(hasMorph); - break; case "intersection": - result = ir.members.some(hasMorph); + for (const member of ir.members) { + if (scanMorph(member, activeAliases)) { + result = true; + break; + } + } break; case "refine": - result = hasMorph(ir.base); + result = scanMorph(ir.base, activeAliases); break; case "tuple": - result = - ir.prefix.some(item => item.hasDefault === true || hasMorph(item.val)) || - (ir.variadic !== undefined && hasMorph(ir.variadic)) || - ir.postfix.some(hasMorph); + for (const item of ir.prefix) { + if (item.hasDefault === true || scanMorph(item.val, activeAliases)) { + result = true; + break; + } + } + if (!result && ir.variadic !== undefined) result = scanMorph(ir.variadic, activeAliases); + if (!result) { + for (const item of ir.postfix) { + if (scanMorph(item, activeAliases)) { + result = true; + break; + } + } + } break; } - ir[kMorph] = result; return result; } +/** + * True when a traversal of `ir` can revisit nodes through recursive aliases, + * requiring cycle guards in the interpreter. Embedded sub-schemas run their + * own guarded traversal and are intentionally not inspected. + */ +export function hasAlias(ir: IR): boolean { + const cached = ir[kAliasOwner] === ir ? ir[kAlias] : undefined; + if (cached !== undefined) return cached; + const result = scanAlias(ir); + ir[kAlias] = result; + ir[kAliasOwner] = ir; + return result; +} + +function scanAlias(ir: IR): boolean { + const cached = ir[kAliasOwner] === ir ? ir[kAlias] : undefined; + if (cached !== undefined) return cached; + switch (ir.k) { + case "alias": + return true; + case "object": + for (const prop of ir.props) if (scanAlias(prop.val)) return true; + if (ir.index !== undefined && scanAlias(ir.index)) return true; + if (ir.symbolIndex !== undefined && scanAlias(ir.symbolIndex)) return true; + if (ir.patternIndexes !== undefined) { + for (const pattern of ir.patternIndexes) { + if (scanAlias(pattern.key) || scanAlias(pattern.val)) return true; + } + } + return false; + case "array": + return scanAlias(ir.el); + case "tuple": + for (const item of ir.prefix) if (scanAlias(item.val)) return true; + if (ir.variadic !== undefined && scanAlias(ir.variadic)) return true; + for (const item of ir.postfix) if (scanAlias(item)) return true; + return false; + case "union": + case "intersection": + for (const member of ir.members) if (scanAlias(member)) return true; + return false; + case "refine": + return scanAlias(ir.base); + case "morph": + return scanAlias(ir.input) || (ir.out !== undefined && scanAlias(ir.out)); + default: + return false; + } +} + /** Human-readable expectation for error messages, e.g. `"a string"`. */ export function expectedOf(ir: IR): string { + if (ir.desc !== undefined) return ir.desc; switch (ir.k) { case "unknown": return "unknown"; @@ -1024,8 +1789,8 @@ export function expectedOf(ir: IR): string { case "morph": return expectedOf(ir.input); case "alias": - return ir.name; + return ir.name === "this" ? expectedOf(ir.resolve()) : ir.name; case "sub": - return expectedOf(ir.schema.ir); + return ir.desc ?? ir.schema.description ?? expectedOf(ir.schema.ir); } } diff --git a/packages/omptype/src/json-schema.ts b/packages/omptype/src/json-schema.ts index a9fdb9873..b5f050831 100644 --- a/packages/omptype/src/json-schema.ts +++ b/packages/omptype/src/json-schema.ts @@ -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 = {}; 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; diff --git a/packages/omptype/src/keywords.ts b/packages/omptype/src/keywords.ts index 0f6174908..2c3bfafe9 100644 --- a/packages/omptype/src/keywords.ts +++ b/packages/omptype/src/keywords.ts @@ -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 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 = {}; + 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"), diff --git a/packages/omptype/src/type.ts b/packages/omptype/src/type.ts index 39134e713..b1a7608bf 100644 --- a/packages/omptype/src/type.ts +++ b/packages/omptype/src/type.ts @@ -12,30 +12,46 @@ */ import { compile, compileAllows } from "./compile"; import { type ErrorConfig, OmpErrors, OmpTypeError, TraversalError } from "./errors"; -import type { InferDef, InferDefIn, InferObjectLiteral, InferString } from "./infer"; +import type { InferDef, InferDefIn, InferObjectLiteral, InferObjectLiteralIn, InferString } from "./infer"; import { walk } from "./interp"; import { type AliasResolver, type Constructor, type Def, + distributeFilter, + type EmbeddableSchema, embed, expectedOf, hasMorph, type IR, IR_BRAND, + isSimpleIR, keyOf, + markThisOnlyResolver, type PropIR, parseDef, + type TupleIR, + useAssignability, } from "./ir"; import { irToJsonSchema, type JsonSchemaOptions } from "./json-schema"; import { keywordIR, patternIR } from "./keywords"; +// `Extract`/`Exclude` in the string DSL need assignability, which is defined here. +useAssignability(isSubtype); + +export interface NarrowErrorInput { + readonly expected: string; + readonly actual?: unknown; + readonly path?: readonly PropertyKey[]; + readonly relativePath?: readonly PropertyKey[]; +} + /** Context passed to `.narrow()` / `.pipe()` callbacks. */ export interface NarrowContext { - /** Record `must be ` and signal failure. */ + readonly path: readonly PropertyKey[]; + error(error: string | NarrowErrorInput): OmpErrors; mustBe(expectation: string): false; - /** Record a custom problem and signal failure. */ - reject(problem: string): false; + reject(problem: string | NarrowErrorInput): OmpErrors | false; } /** Schema metadata and validation-message overrides accepted by `.configure()`. */ @@ -88,6 +104,10 @@ export namespace StandardSchemaV1 { readonly vendor: string; readonly validate: (value: unknown) => Result | Promise>; readonly types?: Types | undefined; + readonly jsonSchema: { + readonly input: (options: StandardJsonSchemaOptions) => Record; + readonly output: (options: StandardJsonSchemaOptions) => Record; + }; } export type Result = SuccessResult | FailureResult; export interface SuccessResult { @@ -107,6 +127,14 @@ export namespace StandardSchemaV1 { } } +export interface StandardJsonSchemaOptions { + readonly target: "draft-2020-12" | "draft-07" | string; + readonly libraryOptions?: { + readonly dialect?: string | null; + readonly fallback?: JsonSchemaOptions["fallback"]; + }; +} + /** A compiled schema: callable validator plus composition methods. */ export interface Type { (data: unknown): t | OmpErrors; @@ -118,11 +146,23 @@ export interface Type { readonly hasDefault: boolean; readonly defaultValue?: unknown; readonly description?: string; + /** Canonical ArkType-compatible expression for diagnostics. */ + readonly expression: string; + /** Canonical structural node representation. */ + readonly json: unknown; /** Full validate+morph pipeline; identical to calling the schema. */ readonly run: (data: unknown) => unknown; + /** ArkType-compatible inference alias (type-only; undefined at runtime). */ + readonly t: t; + /** Scope that parsed this schema (or the ambient Ark-compatible scope). */ + readonly $: TypeScope | { readonly internal: { readonly name: "ark" } }; /** Inference-only output type (no runtime value). */ readonly infer: t; + /** Standalone validator for the schema's accepted input. */ + readonly in: FluentType; + /** Standalone validator for its known output, or `unknown` after an opaque morph. */ + readonly out: FluentType; /** Inference-only input type (no runtime value). */ readonly inferIn: i; @@ -144,27 +184,91 @@ type MergeTypes = left extends object : right : right; +type SimplifyNary = t extends object ? { [key in keyof t]: t[key] } : t; +type UnionToIntersection = (union extends unknown ? (value: union) => void : never) extends ( + value: infer intersection, +) => void + ? intersection + : never; + +type NaryOrOutput = InferDef; +type NaryOrInput = InferDefIn; +type NaryAndOutput = definitions extends readonly [] + ? unknown + : SimplifyNary>>; +type NaryAndInput = definitions extends readonly [] + ? unknown + : SimplifyNary>>; +// biome-ignore lint/complexity/noBannedTypes: generic accumulator default +type ReduceNaryMergeOutput = definitions extends readonly [ + infer head, + ...infer tail, +] + ? ReduceNaryMergeOutput>>> + : definitions extends readonly [] + ? result + : // biome-ignore lint/complexity/noBannedTypes: empty object fallback + {}; +// biome-ignore lint/complexity/noBannedTypes: generic accumulator default +type ReduceNaryMergeInput = definitions extends readonly [ + infer head, + ...infer tail, +] + ? ReduceNaryMergeInput>>> + : definitions extends readonly [] + ? result + : // biome-ignore lint/complexity/noBannedTypes: empty object fallback + {}; +type NaryMergeOutput = definitions extends readonly [] + ? object + : ReduceNaryMergeOutput; +type NaryMergeInput = definitions extends readonly [] + ? object + : ReduceNaryMergeInput; + +type PipeItemOutput = + item extends SchemaInference + ? output + : item extends (data: never, ...arguments_: never[]) => infer output + ? Exclude + : InferDef; +type NaryPipeOutput = items extends readonly [...(readonly unknown[]), infer last] + ? PipeItemOutput + : unknown; +type NaryPipeInput = items extends readonly [infer first, ...(readonly unknown[])] + ? first extends SchemaInference + ? input + : first extends (data: infer input, ...arguments_: never[]) => unknown + ? input + : InferDefIn + : unknown; + interface FluentMethods { describe(description: string): FluentType; - configure(config: SchemaConfig): FluentType; - default(value: t | (() => t)): FluentType; + configure(config: SchemaConfig, selector?: "self" | ConfigureSelector): FluentType; + + default(value: i | (() => i)): FluentType; optional(): readonly [SchemaInference, "?"]; or(def: SchemaInference): FluentType; or(def: def): FluentType, i | InferString>; or>( def: def, - ): FluentType, i | InferObjectLiteral>; + ): FluentType, i | InferObjectLiteralIn>; or(def: Def): FluentType; and(def: SchemaInference): FluentType; and>( def: def, - ): FluentType, i & InferObjectLiteral>; + ): FluentType, i & InferObjectLiteralIn>; and(def: Def): FluentType; equals(def: Def): boolean; ifEquals(def: Def): FluentType | undefined; + ifExtends(def: Def): FluentType | undefined; extends(def: Def): boolean; overlaps(def: Def): boolean; - distribute(mapper: (branch: FluentType) => SchemaInference): FluentType; + distribute( + mapper: (branch: FluentType) => SchemaInference, + reducer?: (branches: readonly SchemaInference[]) => SchemaInference, + ): FluentType; select(kind: string): readonly SelectedNode[]; array(): FluentType; atLeastLength(bound: number): FluentType; @@ -182,18 +286,19 @@ interface FluentMethods { nonNegative(): FluentType; nonPositive(): FluentType; matching(pattern: RegExp): FluentType; - atOrAfter(bound: Date): FluentType; - atOrBefore(bound: Date): FluentType; - laterThan(bound: Date): FluentType; - earlierThan(bound: Date): FluentType; - pipe(fn: (data: t, ctx: NarrowContext) => r): FluentType, i>; + atOrAfter(bound: Date | number): FluentType; + atOrBefore(bound: Date | number): FluentType; + laterThan(bound: Date | number): FluentType; + earlierThan(bound: Date | number): FluentType; + readonly pipe: PipeMethod; to(def: def): FluentType, i>; filter(fn: (data: i, ctx: NarrowContext) => data is narrowed): FluentType; - filter(fn: (data: i, ctx: NarrowContext) => boolean): FluentType; + filter(fn: (data: i, ctx: NarrowContext) => boolean | OmpErrors): FluentType; narrow(fn: (data: t, ctx: NarrowContext) => data is narrowed): FluentType; - narrow(fn: (data: t, ctx: NarrowContext) => boolean): FluentType; + narrow(fn: (data: t, ctx: NarrowContext) => boolean | OmpErrors): FluentType; brand(name: name): FluentType, i>; as(): FluentType; + readonly(): FluentType, i>; extract(def: SchemaInference): FluentType, Extract>; extract(def: def): FluentType>, Extract>>; extract(def: Def): FluentType; @@ -204,6 +309,16 @@ interface FluentMethods { onDeepUndeclaredKey(behavior: "ignore" | "reject" | "delete"): FluentType; } +interface PipeMethod { + (fn: (data: t, ctx: NarrowContext) => r): FluentType, i>; + (schema: SchemaInference): FluentType; + (...steps: readonly unknown[]): FluentType; + readonly try: { + (fn: (data: t, ctx: NarrowContext) => r): FluentType, i>; + (...steps: readonly unknown[]): FluentType; + }; +} + type InputObject = i extends object ? i : object; interface ObjectMethods { @@ -212,9 +327,7 @@ interface ObjectMethods { mapper: (property: TypeProperty) => TypeProperty | readonly TypeProperty[], ): FluentType>; keyof(): FluentType, Extract, PropertyKey>>; - get( - key: key, - ): FluentType ? InputObject[key] : unknown>; + get(...path: path): FluentType; pick( ...keys: keys ): FluentType, Pick, Extract>>>; @@ -226,7 +339,7 @@ interface ObjectMethods { merge(def: SchemaInference): FluentType, MergeTypes>; merge>( def: def, - ): FluentType>, MergeTypes>>; + ): FluentType>, MergeTypes>>; merge(def: Def): FluentType; } @@ -240,54 +353,221 @@ type ObjectMethodsFor = [t] extends [never] /** Callable schema with fluent methods specialized to its output and input. */ export type FluentType = Type & FluentMethods & ObjectMethodsFor; +type FnDefinition = Def | SchemaInference; + +/** Function returned by `type.fn`: arguments and an optional return are validated at every call. */ +export type TypedFunction< + parameters extends readonly unknown[] = readonly unknown[], + returns = unknown, + declaredReturns = returns, +> = ((...arguments_: parameters) => returns) & { + readonly params: FluentType; + readonly returns: FluentType; + readonly expression: string; + readonly raw: (...arguments_: parameters) => returns; +}; + +type InferFnDefinition = + definition extends SchemaInference ? output : InferDef; + +type InferFnParameters< + definitions extends readonly unknown[], + accumulator extends readonly unknown[] = [], +> = definitions extends readonly [infer head, ...infer tail] + ? head extends ":" + ? accumulator + : InferFnParameters]> + : accumulator; + +type InferFnReturn = definitions extends readonly [ + ...(readonly unknown[]), + ":", + infer returns, +] + ? InferFnDefinition + : inferred; + +type DeclaredFnReturn = definitions extends readonly [ + ...(readonly unknown[]), + ":", + infer returns, +] + ? InferFnDefinition + : unknown; + +type FnFactory = ( + implementation: (...arguments_: InferFnParameters) => InferFnReturn, +) => TypedFunction, InferFnReturn, DeclaredFnReturn>; + +/** Parses function parameter schemas and validates calls and declared returns. */ +export interface FnParser { + (...definitions: definitions): FnFactory; + raw(...definitions: definitions): FnFactory; +} +interface Step { + kind: "pipe" | "narrow" | "filter"; + fn: (data: unknown, ctx: NarrowContext) => unknown; + /** Convert thrown callback exceptions into validation errors. */ + try?: boolean; + /** Output IR when the step validates its output; drives public `.out`. */ + out?: IR; +} /** Runtime constructor-like value used by ArkType-compatible `instanceof Type` checks. */ export const Type = Object.defineProperty(function Type(): void {}, Symbol.hasInstance, { value: (value: unknown): boolean => (typeof value === "function" || (typeof value === "object" && value !== null)) && IR_BRAND in value, }); -interface Step { - kind: "pipe" | "narrow" | "filter"; - fn: (data: unknown, ctx: NarrowContext) => unknown; - /** Output IR when the step is a `.to(target)` pipe; drives `io: 'output'` JSON Schema. */ - out?: IR; -} - interface TypeMeta { description?: string; defaultValue?: unknown; hasDefault?: boolean; + defaultOutput?: unknown; + hasDefaultOutput?: boolean; errorConfig?: ErrorConfig; + clone?: false | ((input: unknown) => unknown); +} +function descriptionOf(ir: IR, seen: Set = new Set()): string { + if (ir.desc !== undefined) return ir.desc; + if (seen.has(ir)) return ir.k === "alias" ? ir.name : expectedOf(ir); + seen.add(ir); + if (ir.k === "alias") return descriptionOf(ir.resolve(), seen); + if (ir.k === "object") { + return `{ ${ir.props.map(prop => `${String(prop.key)}${prop.opt ? "?" : ""}: ${descriptionOf(prop.val, seen)}`).join(", ")} }`; + } + return expectedOf(ir); +} + +export interface ConfigureSelector { + readonly kind?: string; + readonly where?: (node: { readonly domain?: string; readonly kind: string }) => boolean; +} + +function errorConfigOf(config: SchemaConfig): ErrorConfig { + return { + ...(config.description === undefined || config.expected !== undefined ? {} : { expected: config.description }), + ...(config.expected === undefined ? {} : { expected: config.expected }), + ...(config.actual === undefined ? {} : { actual: config.actual }), + ...(config.problem === undefined ? {} : { problem: config.problem }), + ...(config.message === undefined ? {} : { message: config.message }), + }; +} + +function configureNode(ir: IR, config: SchemaConfig): IR { + return { + ...ir, + cfg: { ...ir.cfg, ...errorConfigOf(config) }, + ...(config.description === undefined ? {} : { desc: config.description }), + }; +} + +function configureSelected(ir: IR, config: SchemaConfig, selector: ConfigureSelector): IR { + const domain = + ir.k === "string" || ir.k === "number" || ir.k === "boolean" || ir.k === "bigint" || ir.k === "symbol" + ? ir.k + : ir.k === "object" || ir.k === "array" || ir.k === "tuple" || ir.k === "instance" || ir.k === "anyobject" + ? "object" + : undefined; + const kind = ir.k === "number" && ir.divisor !== undefined ? "divisor" : "domain"; + if ( + (selector.kind === undefined || selector.kind === kind) && + (selector.where === undefined || selector.where({ kind, domain })) + ) { + return configureNode(ir, config); + } + switch (ir.k) { + case "array": + return { ...ir, el: configureSelected(ir.el, config, selector) }; + case "tuple": + return { + ...ir, + prefix: ir.prefix.map(item => ({ ...item, val: configureSelected(item.val, config, selector) })), + ...(ir.variadic === undefined ? {} : { variadic: configureSelected(ir.variadic, config, selector) }), + postfix: ir.postfix.map(item => configureSelected(item, config, selector)), + }; + case "object": + return { + ...ir, + props: ir.props.map(prop => ({ ...prop, val: configureSelected(prop.val, config, selector) })), + ...(ir.index === undefined ? {} : { index: configureSelected(ir.index, config, selector) }), + symbolIndex: ir.symbolIndex === undefined ? undefined : configureSelected(ir.symbolIndex, config, selector), + patternIndexes: ir.patternIndexes?.map(index => ({ + key: configureSelected(index.key, config, selector), + val: configureSelected(index.val, config, selector), + })), + }; + case "union": + case "intersection": + return { ...ir, members: ir.members.map(member => configureSelected(member, config, selector)) }; + case "refine": + return { ...ir, base: configureSelected(ir.base, config, selector) }; + case "morph": + return { + ...ir, + input: configureSelected(ir.input, config, selector), + ...(ir.out === undefined ? {} : { out: configureSelected(ir.out, config, selector) }), + }; + default: + return ir; + } } class Ctx implements NarrowContext { expectation: string | undefined; + errors: OmpErrors | undefined; + readonly path: readonly PropertyKey[]; + #data: unknown; + + constructor(data: unknown, path: readonly PropertyKey[] = []) { + this.#data = data; + this.path = path.map(key => (typeof key === "symbol" ? String(key) : key)); + } + + error(input: string | NarrowErrorInput): OmpErrors { + const detail = typeof input === "string" ? { expected: input } : input; + const error = OmpErrors.single([...(detail.path ?? detail.relativePath ?? [])], detail.expected, this.#data, { + preserveActual: true, + ...(Object.hasOwn(detail, "actual") ? { actual: String(detail.actual) } : {}), + }); + if (this.errors) this.errors.append(error); + else this.errors = error; + return error; + } mustBe(expectation: string): false { this.expectation = expectation; return false; } - reject(problem: string): false { - this.expectation = problem; + reject(input: string | NarrowErrorInput): OmpErrors | false { + if (typeof input === "string") { + this.expectation = input; + return false; + } + this.error(input); return false; } } -type Validator = (data: unknown) => unknown; -type Allows = (data: unknown) => boolean; +type Validator = (data: unknown, path?: readonly PropertyKey[]) => unknown; +type Allows = (data: unknown) => data is unknown; const kBase = Symbol("omptype.base"); const kSteps = Symbol("omptype.steps"); const EMPTY_STEPS: Step[] = []; const EMPTY_META: TypeMeta = {}; +const ARK_COMPAT_SCOPE = Object.freeze({ internal: Object.freeze({ name: "ark" as const }) }); -interface InternalType { +interface InternalType + extends Type, + FluentMethods, + ObjectMethods, unknown> { (data: unknown): unknown; [IR_BRAND]: true; [kBase]: Validator; [kSteps]: Step[]; + clone?: false | ((input: unknown) => unknown); allows: Allows; ir: IR; hasSteps: boolean; @@ -297,8 +577,12 @@ interface InternalType { /** True when the last pipe step is bare — output shape statically unknown. */ opaqueOutput?: boolean; defaultValue?: unknown; + defaultOutput?: unknown; + hasDefaultOutput?: boolean; description?: string; errorConfig?: ErrorConfig; + $: typeof ARK_COMPAT_SCOPE; + resolver?: AliasResolver; run: Validator; } @@ -310,38 +594,143 @@ function metaOf(schema: InternalType): TypeMeta { description: schema.description, defaultValue: schema.defaultValue, hasDefault: schema.hasDefault, + defaultOutput: schema.defaultOutput, + hasDefaultOutput: schema.hasDefaultOutput, errorConfig: schema.errorConfig, + clone: schema.clone, }; } +function inheritScope(source: InternalType, target: InternalType): InternalType { + if (source.resolver === undefined) return target; + target.resolver = source.resolver; + Reflect.set(target, "$", source.$); + return target; +} +function invalidDefault(label: string, errors: OmpErrors): never { + const error = errors[0]; + let heading = label; + for (let index = 0; index < error.path.length; index++) { + const segment = error.path[index]; + if (typeof segment === "number") { + if (label === "Default" && index === 0) heading = "Default value"; + heading += ` at [${segment}]`; + } else if (label === "Default" && index === 0) { + heading += ` ${String(segment)}`; + } else { + heading += `.${String(segment)}`; + } + } + throw new OmpTypeError(`ParseError: ${heading} ${error.problem}`); +} + +function rejectMutableStaticDefault(value: unknown): void { + if (value !== null && typeof value === "object" && !(value instanceof Date)) { + throw new OmpTypeError("ParseError: A mutable default value must be specified as a factory"); + } +} + +function normalizeDefaults(ir: IR, seen = new WeakSet()): void { + if (seen.has(ir)) return; + seen.add(ir); + switch (ir.k) { + case "object": + for (const prop of ir.props) { + normalizeDefaults(prop.val, seen); + if (!prop.hasDefault || prop.defValidated) continue; + let candidate: unknown; + let factory = false; + if (prop.defFactory && typeof prop.def === "function") { + candidate = prop.def(); + factory = true; + } else { + rejectMutableStaticDefault(prop.def); + candidate = prop.def; + } + const output = walk(prop.val, candidate); + if (output instanceof OmpErrors) invalidDefault(`Default for ${String(prop.key)}`, output); + if (!factory) prop.def = output; + prop.defValidated = true; + } + if (ir.index) normalizeDefaults(ir.index, seen); + if (ir.symbolIndex) normalizeDefaults(ir.symbolIndex, seen); + if (ir.patternIndexes) { + for (const index of ir.patternIndexes) { + normalizeDefaults(index.key, seen); + normalizeDefaults(index.val, seen); + } + } + return; + case "tuple": + for (let index = 0; index < ir.prefix.length; index++) { + const item = ir.prefix[index]; + normalizeDefaults(item.val, seen); + if (!item.hasDefault || item.defValidated) continue; + let candidate: unknown; + let factory = false; + if (item.defFactory && typeof item.def === "function") { + candidate = item.def(); + factory = true; + } else { + rejectMutableStaticDefault(item.def); + candidate = item.def; + } + const output = walk(item.val, candidate); + if (output instanceof OmpErrors) invalidDefault(`Default for [${index}]`, output); + if (!factory) item.def = output; + item.defValidated = true; + } + if (ir.variadic) normalizeDefaults(ir.variadic, seen); + for (const item of ir.postfix) normalizeDefaults(item, seen); + return; + case "array": + normalizeDefaults(ir.el, seen); + return; + case "union": + case "intersection": + for (const member of ir.members) normalizeDefaults(member, seen); + return; + case "refine": + normalizeDefaults(ir.base, seen); + return; + case "morph": + normalizeDefaults(ir.input, seen); + if (ir.out) normalizeDefaults(ir.out, seen); + return; + default: + return; + } +} /** Emitted for `io: 'output'` when a bare pipe makes the output unknowable. */ const OPAQUE_OUTPUT_IR: IR = { k: "unknown" }; - const typeMethods = { describe(this: InternalType, description: string): InternalType { - return makeType({ ...this.ir, desc: description }, this[kSteps], { ...metaOf(this), description }); + const ir = { ...this.ir, desc: description, cfg: { ...this.ir.cfg, expected: description } }; + return makeType(ir, this[kSteps], { ...metaOf(this), description }); }, - configure(this: InternalType, config: SchemaConfig): InternalType { - const errorConfig: ErrorConfig = { - ...this.errorConfig, - ...(config.expected === undefined ? {} : { expected: config.expected }), - ...(config.actual === undefined ? {} : { actual: config.actual }), - ...(config.problem === undefined ? {} : { problem: config.problem }), - ...(config.message === undefined ? {} : { message: config.message }), - }; - const meta = { + configure(this: InternalType, config: SchemaConfig, selector: "self" | ConfigureSelector = "self"): InternalType { + const selected = + selector === "self" ? configureNode(this.ir, config) : configureSelected(this.ir, config, selector); + return makeType(selected, this[kSteps], { ...metaOf(this), - errorConfig, + errorConfig: { ...this.errorConfig, ...errorConfigOf(config) }, ...(config.description === undefined ? {} : { description: config.description }), - }; - return config.description === undefined - ? makeType(this.ir, this[kSteps], meta) - : makeType({ ...this.ir, desc: config.description }, this[kSteps], meta); + }); }, default(this: InternalType, value: unknown): InternalType { - return makeType(this.ir, this[kSteps], { ...metaOf(this), defaultValue: value, hasDefault: true }); + const factory = typeof value === "function"; + if (!factory) rejectMutableStaticDefault(value); + const candidate = factory ? value() : value; + const output = this.run(candidate); + if (output instanceof OmpErrors) invalidDefault("Default", output); + return makeType(this.ir, this[kSteps], { + ...metaOf(this), + defaultValue: value, + hasDefault: true, + ...(factory ? {} : { defaultOutput: output, hasDefaultOutput: true }), + }); }, optional(this: InternalType): readonly [InternalType, "?"] { @@ -349,27 +738,31 @@ const typeMethods = { }, or(this: InternalType, def: Def): InternalType { - const other = parseDef(def); + const other = parseDef(def, this.resolver); const a = embed(this); const members = [...(a.k === "union" ? a.members : [a]), ...(other.k === "union" ? other.members : [other])]; - return makeType({ k: "union", members }, [], {}); + return inheritScope(this, makeType({ k: "union", members }, [], {})); }, equals(this: InternalType, def: Def): boolean { - return irEquals(embed(this), parseDef(def)); + return irEquals(embed(this), parseDef(def, this.resolver)); }, ifEquals(this: InternalType, def: Def): InternalType | undefined { - return irEquals(embed(this), parseDef(def)) ? this : undefined; + return irEquals(embed(this), parseDef(def, this.resolver)) ? this : undefined; + }, + + ifExtends(this: InternalType, def: Def): InternalType | undefined { + return isSubtype(embed(this), parseDef(def, this.resolver)) ? this : undefined; }, extends(this: InternalType, def: Def): boolean { - return isSubtype(embed(this), parseDef(def)); + return isSubtype(embed(this), parseDef(def, this.resolver)); }, overlaps(this: InternalType, def: Def): boolean { try { - intersect(embed(this), parseDef(def)); + intersect(embed(this), parseDef(def, this.resolver)); return true; } catch (error) { if (error instanceof OmpTypeError) return false; @@ -377,9 +770,15 @@ const typeMethods = { } }, - distribute(this: InternalType, mapper: (branch: BaseType) => InternalType): InternalType { + distribute( + this: InternalType, + mapper: (branch: BaseType) => InternalType, + reducer?: (branches: readonly InternalType[]) => InternalType, + ): InternalType { const branches = this.ir.k === "union" ? this.ir.members : [embed(this)]; - const members = branches.map(branch => embed(mapper(makeType(branch, [], {}) as unknown as BaseType))); + const mapped = branches.map(branch => mapper(makeType(branch, [], {}) as unknown as BaseType)); + if (reducer !== undefined) return reducer(mapped); + const members = mapped.map(branch => embed(branch)); return makeType(members.length === 1 ? members[0] : { k: "union", members }, [], {}); }, @@ -388,11 +787,11 @@ const typeMethods = { }, and(this: InternalType, def: Def): InternalType { - return makeType(intersect(embed(this), parseDef(def)), [], {}); + return inheritScope(this, makeType(intersect(embed(this), parseDef(def, this.resolver)), [], {})); }, array(this: InternalType): InternalType { - return makeType({ k: "array", el: embed(this) }, [], {}); + return inheritScope(this, makeType({ k: "array", el: embed(this) }, [], {})); }, atLeastLength(this: InternalType, bound: number): InternalType { @@ -458,40 +857,32 @@ const typeMethods = { return makeType(intersect(this.ir, patternIR(pattern)), this[kSteps], metaOf(this)); }, - atOrAfter(this: InternalType, bound: Date): InternalType { - return dateRefinement(this, bound, "at or after", value => value >= bound.valueOf()); + atOrAfter(this: InternalType, bound: Date | number): InternalType { + const timestamp = bound instanceof Date ? bound.valueOf() : bound; + return dateRefinement(this, timestamp, "at or after", value => value >= timestamp); }, - atOrBefore(this: InternalType, bound: Date): InternalType { - return dateRefinement(this, bound, "at or before", value => value <= bound.valueOf()); + atOrBefore(this: InternalType, bound: Date | number): InternalType { + const timestamp = bound instanceof Date ? bound.valueOf() : bound; + return dateRefinement(this, timestamp, "at or before", value => value <= timestamp); }, - laterThan(this: InternalType, bound: Date): InternalType { - return dateRefinement(this, bound, "later than", value => value > bound.valueOf()); + laterThan(this: InternalType, bound: Date | number): InternalType { + const timestamp = bound instanceof Date ? bound.valueOf() : bound; + return dateRefinement(this, timestamp, "later than", value => value > timestamp); }, - earlierThan(this: InternalType, bound: Date): InternalType { - return dateRefinement(this, bound, "earlier than", value => value < bound.valueOf()); + earlierThan(this: InternalType, bound: Date | number): InternalType { + const timestamp = bound instanceof Date ? bound.valueOf() : bound; + return dateRefinement(this, timestamp, "earlier than", value => value < timestamp); }, - pipe(this: InternalType, fn: Step["fn"]): InternalType { - return makeType(this.ir, [...this[kSteps], { kind: "pipe", fn }], metaOf(this)); + pipe(this: InternalType, ...pipes: readonly unknown[]): InternalType { + return appendPipes(this, pipes, false); }, to(this: InternalType, def: unknown): InternalType { - const output = makeType(parseDef(def), [], {}); - return makeType( - this.ir, - [ - ...this[kSteps], - { - kind: "pipe", - fn: value => output(value), - out: output.ir, - }, - ], - metaOf(this), - ); + return appendPipes(this, [makeType(parseDef(def, this.resolver), [], {})], false, true); }, filter(this: InternalType, fn: Step["fn"]): InternalType { @@ -503,98 +894,103 @@ const typeMethods = { }, brand(this: InternalType): InternalType { - return makeType(this.ir, this[kSteps], metaOf(this)); + return this; }, as(this: InternalType): InternalType { - return makeType(this.ir, this[kSteps], metaOf(this)); + return this; + }, + + readonly(this: InternalType): InternalType { + return this; }, keyof(this: InternalType): InternalType { return makeType(keyOf(this.ir), [], {}); }, - get(this: InternalType, key: PropertyKey): InternalType { - const object = requireObject(this.ir, "get"); - const prop = object.props.find(candidate => candidate.key === String(key)); - if (!prop) throw new OmpTypeError(`key ${String(key)} is not declared`); - const ir: IR = prop.opt ? { k: "union", members: [prop.val, { k: "undefined" }] } : prop.val; - return makeType(ir, [], {}); + get(this: InternalType, ...path: PropertyKey[]): InternalType { + if (path.length === 0) return this; + let result = this.ir; + for (const key of path) result = getPathIR(result, key); + return makeType(result, [], {}); }, pick(this: InternalType, ...keys: PropertyKey[]): InternalType { - const object = requireObject(this.ir, "pick"); - const selected = new Set(keys.map(String)); - return makeType({ ...object, props: object.props.filter(prop => selected.has(prop.key)) }, [], {}); + return makeType(selectObjectProps(this.ir, keys, true, "pick"), [], {}); }, omit(this: InternalType, ...keys: PropertyKey[]): InternalType { - const object = requireObject(this.ir, "omit"); - const omitted = new Set(keys.map(String)); - return makeType({ ...object, props: object.props.filter(prop => !omitted.has(prop.key)) }, [], {}); + return makeType(selectObjectProps(this.ir, keys, false, "omit"), [], {}); }, partial(this: InternalType): InternalType { - const object = requireObject(this.ir, "partial"); - return makeType({ ...object, props: object.props.map(prop => ({ ...prop, opt: true })) }, [], {}); + return makeType(setObjectOptionality(this.ir, true, "partial"), [], {}); }, required(this: InternalType): InternalType { - const object = requireObject(this.ir, "required"); - return makeType({ ...object, props: object.props.map(prop => ({ ...prop, opt: false })) }, [], {}); + return makeType(setObjectOptionality(this.ir, false, "required"), [], {}); }, map(this: InternalType, mapper: (property: TypeProperty) => TypeProperty | readonly TypeProperty[]): InternalType { const object = requireObject(this.ir, "map"); const props = object.props.flatMap(prop => { - const mapped = mapper(propertyFromIR(prop)); - return (Array.isArray(mapped) ? mapped : [mapped]).map(propertyToIR); + const original = propertyFromIR(prop); + const mapped = mapper(original); + return (Array.isArray(mapped) ? mapped : [mapped]).map(property => + propertyToIR( + (property.kind === "required" || property.kind === "optional" + ? property + : { ...property, kind: original.kind }) as TypeProperty, + ), + ); }); return makeType({ ...object, props }, [], {}); }, merge(this: InternalType, def: unknown): InternalType { - return makeType(mergeObjects(requireObject(this.ir, "merge"), requireObject(parseDef(def), "merge")), [], {}); + const merged = mergeObjectDefinition(this.ir, def, this.resolver); + return inheritScope(this, makeType(merged, [], {})); }, extract(this: InternalType, def: unknown): InternalType { - const other = parseDef(def); - return makeType( - { - k: "refine", - base: embed(this), - pred: value => !(walk(other, value) instanceof OmpErrors), - expected: "a value included by the extracted type", - }, - [], - {}, - ); + return inheritScope(this, makeType(distributeFilter(this.ir, parseDef(def, this.resolver), true), [], {})); }, exclude(this: InternalType, def: unknown): InternalType { - const other = parseDef(def); - return makeType( - { - k: "refine", - base: embed(this), - pred: value => walk(other, value) instanceof OmpErrors, - expected: "a value not excluded by the type", - }, - [], - {}, - ); + return inheritScope(this, makeType(distributeFilter(this.ir, parseDef(def, this.resolver), false), [], {})); }, onUndeclaredKey(this: InternalType, behavior: "ignore" | "reject" | "delete"): InternalType { - const object = requireObject(this.ir, "onUndeclaredKey"); - return makeType({ ...object, extras: behavior === "ignore" ? "keep" : behavior }, this[kSteps], metaOf(this)); + const extras = behavior === "ignore" ? "keep" : behavior; + const ir = withShallowExtras(this.ir, extras); + if (extras === "delete" && ir.k === "union") { + const objects = ir.members.filter((member): member is ObjectIR => member.k === "object"); + for (let left = 0; left < objects.length; left++) { + for (let right = left + 1; right < objects.length; right++) { + const sharedRequired = objects[left].props.some( + leftProp => + !leftProp.opt && + objects[right].props.some(rightProp => !rightProp.opt && rightProp.key === leftProp.key), + ); + if (!sharedRequired) { + const leftExpression = expressionOf(objects[left]).replace(/ }$/, ", + (undeclared): delete }"); + const rightExpression = expressionOf(objects[right]).replace(/ }$/, ", + (undeclared): delete }"); + throw new OmpTypeError( + `ParseError: An unordered union of a type including a morph and a type with overlapping input is indeterminate:\nLeft: ${leftExpression}\nRight: ${rightExpression}`, + ); + } + } + } + } + return makeType(ir, this[kSteps], metaOf(this)); }, onDeepUndeclaredKey(this: InternalType, behavior: "ignore" | "reject" | "delete"): InternalType { return makeType(withDeepExtras(this.ir, behavior === "ignore" ? "keep" : behavior), this[kSteps], metaOf(this)); }, - allows(this: InternalType, data: unknown): boolean { + allows(this: InternalType, data: unknown): data is unknown { const steps = this[kSteps]; let needsPredicates = false; for (const step of steps) { @@ -610,12 +1006,12 @@ const typeMethods = { return allows(data); } for (const step of steps) { - if (step.kind === "filter" && !step.fn(data, new Ctx())) return false; + if (step.kind === "filter" && !step.fn(data, new Ctx(data))) return false; } const out = this[kBase](data); if (out instanceof OmpErrors) return false; for (const step of steps) { - if (step.kind === "narrow" && !step.fn(out, new Ctx())) return false; + if (step.kind === "narrow" && !step.fn(out, new Ctx(out))) return false; } return true; }, @@ -635,16 +1031,27 @@ const typeMethods = { toJsonSchema(this: InternalType, options?: ToJsonSchemaOptions): Record { const ir = options?.io === "output" ? (this.opaqueOutput ? OPAQUE_OUTPUT_IR : (this.stepOut ?? this.ir)) : this.ir; - const description = options?.description ?? this.description; - return irToJsonSchema( - ir, - options === undefined && description === undefined - ? undefined - : { ...options, ...(description === undefined ? {} : { description }) }, - ); + const description = options?.description ?? this.ir.desc; + if (description === undefined) return irToJsonSchema(ir, options); + return irToJsonSchema(ir, { ...options, description }); }, }; +Object.defineProperty(typeMethods, "expression", { + get(this: InternalType): string { + const input = expressionOf(this.ir); + if (!this.hasSteps) return input; + if (this.opaqueOutput) return `(In: ${input}) => Out`; + return `(In: ${input}) => To<${expressionOf(this.stepOut ?? this.ir)}>`; + }, +}); + +Object.defineProperty(typeMethods, "json", { + get(this: InternalType): unknown { + return arkJsonOf(this.ir); + }, +}); + Object.defineProperty(typeMethods, "props", { get(this: InternalType): readonly TypeProperty[] { const object = requireObject(this.ir, "props"); @@ -654,20 +1061,85 @@ Object.defineProperty(typeMethods, "props", { Object.defineProperty(typeMethods, "~standard", { get(this: InternalType): StandardSchemaV1.Props { + const jsonSchema = (io: "input" | "output", options: StandardJsonSchemaOptions) => { + if (options.target !== "draft-2020-12" && options.target !== "draft-07") { + throw new OmpTypeError( + `JSONSchema target '${options.target}' is not supported (must be "draft-2020-12" or "draft-07")`, + ); + } + return this.toJsonSchema({ ...options.libraryOptions, target: options.target, io }); + }; return { version: 1, vendor: "omptype", validate: (value: unknown): StandardSchemaV1.Result => { const out = this.run(value); - if (out instanceof OmpErrors) { - return { issues: out.map(error => ({ message: error.problem, path: error.path })) }; - } - return { value: out }; + return out instanceof OmpErrors + ? { issues: out as unknown as readonly StandardSchemaV1.Issue[] } + : { value: out }; + }, + jsonSchema: { + input: options => jsonSchema("input", options), + output: options => jsonSchema("output", options), }, }; }, }); +Object.defineProperty(typeMethods, "in", { + get(this: InternalType): InternalType { + return makeType(projectIO(this.ir, "in"), [], {}); + }, +}); + +Object.defineProperty(typeMethods, "out", { + get(this: InternalType): InternalType { + if (this.opaqueOutput) return makeType({ k: "unknown" }, [], {}); + return makeType(projectIO(this.stepOut ?? this.ir, "out"), [], {}); + }, +}); + +const allowsMethod = typeMethods.allows; +const assertMethod = typeMethods.assert; +const fromMethod = typeMethods.from; +Object.defineProperties(typeMethods, { + description: { + get(this: InternalType): string { + return descriptionOf(this.ir); + }, + }, + allows: { + get(this: InternalType): Allows { + const allows = (data: unknown): data is unknown => allowsMethod.call(this, data); + Object.defineProperty(this, "allows", { value: allows, writable: true }); + return allows; + }, + }, + assert: { + get(this: InternalType): Validator { + const assert = assertMethod.bind(this); + Object.defineProperty(this, "assert", { value: assert }); + return assert; + }, + }, + from: { + get(this: InternalType): Validator { + const from = fromMethod.bind(this); + Object.defineProperty(this, "from", { value: from }); + return from; + }, + }, + pipe: { + get(this: InternalType) { + const pipe = Object.assign((...pipes: readonly unknown[]): InternalType => appendPipes(this, pipes, false), { + try: (...pipes: readonly unknown[]): InternalType => appendPipes(this, pipes, true), + }); + Object.defineProperty(this, "pipe", { value: pipe }); + return pipe; + }, + }, +}); + // Share the fluent surface without per-schema method allocations or copies. // Function.prototype remains in the chain, except bind is intentionally hidden // so generic tool wrappers recognize callable schemas rather than rebinding them. @@ -677,6 +1149,15 @@ Object.defineProperty(typeMethods, "bind", { value: undefined }); function makeType(ir: IR, steps: Step[], meta: TypeMeta): InternalType; function makeType(ir: IR, steps: Step[], meta: TypeMeta): FluentType; function makeType(ir: IR, steps: Step[], meta: TypeMeta): unknown { + let morph = false; + if (!isSimpleIR(ir)) { + ir = normalizeIR(ir); + morph = hasMorph(ir); + if (morph) { + normalizeDefaults(ir); + assertDeterminateMorphUnions(ir); + } + } let calls = 0; let impl: Validator = (data: unknown): unknown => { if (++calls >= JIT_THRESHOLD) { @@ -686,27 +1167,32 @@ function makeType(ir: IR, steps: Step[], meta: TypeMeta): unknown { return walk(ir, data); }; - const base: Validator = - meta.errorConfig === undefined - ? (data: unknown): unknown => impl(data) - : (data: unknown): unknown => { - const result = impl(data); - return result instanceof OmpErrors ? result.configure(meta.errorConfig ?? {}) : result; - }; + const base: Validator = (data: unknown): unknown => impl(data); + const errorConfig = meta.errorConfig ?? ir.cfg; - const callable: Validator = + const filterInput = steps.some(step => step.kind === "filter") ? projectIO(ir, "in") : undefined; + const validate: Validator = steps.length === 0 ? base - : (data: unknown): unknown => { + : (data: unknown, contextPath: readonly PropertyKey[] = []): unknown => { + if (filterInput !== undefined) { + const inputResult = walk(filterInput, data); + if (inputResult instanceof OmpErrors) return inputResult; + } for (const step of steps) { if (step.kind !== "filter") continue; - const ctx = new Ctx(); - if (!step.fn(data, ctx)) { + const ctx = new Ctx(data, contextPath); + const result = step.fn(data, ctx); + if (result instanceof OmpErrors) + return errorConfig === undefined ? result : result.configure(errorConfig); + if (ctx.errors) return errorConfig === undefined ? ctx.errors : ctx.errors.configure(errorConfig); + if (!result) { return OmpErrors.single( [], - ctx.expectation ?? "valid (input predicate failed)", + ctx.expectation ?? + (step.fn.name ? `valid according to ${step.fn.name}` : "valid (input predicate failed)"), data, - meta.errorConfig, + errorConfig, ); } } @@ -714,25 +1200,85 @@ function makeType(ir: IR, steps: Step[], meta: TypeMeta): unknown { if (out instanceof OmpErrors) return out; for (const step of steps) { if (step.kind === "filter") continue; - const ctx = new Ctx(); + const ctx = new Ctx(out, contextPath); if (step.kind === "narrow") { - if (!step.fn(out, ctx)) { + const result = step.fn(out, ctx); + if (result instanceof OmpErrors) { + return errorConfig === undefined ? result : result.configure(errorConfig); + } + if (ctx.errors) return errorConfig === undefined ? ctx.errors : ctx.errors.configure(errorConfig); + if (!result) { return OmpErrors.single( [], - ctx.expectation ?? "valid (narrow predicate failed)", + ctx.expectation ?? + (step.fn.name ? `valid according to ${step.fn.name}` : "valid (narrow predicate failed)"), out, - meta.errorConfig, + errorConfig, ); } } else { - out = step.fn(out, ctx); + try { + out = step.fn(out, ctx); + } catch (error) { + if (!step.try) throw error; + const detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error); + return OmpErrors.single([], `valid (morph threw ${detail})`, out, errorConfig); + } if (out instanceof OmpErrors) { - return meta.errorConfig === undefined ? out : out.configure(meta.errorConfig); + return errorConfig === undefined ? out : out.configure(errorConfig); } } } return out; }; + const needsClone = morph || steps.some(step => step.kind === "pipe"); + const clone = meta.clone; + let callable: Validator = validate; + if (needsClone && clone !== undefined) { + if (clone === false) { + callable = (data: unknown, path?: readonly PropertyKey[]): unknown => { + const out = validate(data, path); + if ( + out instanceof OmpErrors || + out === data || + typeof data !== "object" || + data === null || + typeof out !== "object" || + out === null + ) { + return out; + } + if (Array.isArray(data) && Array.isArray(out)) { + data.splice(0, data.length, ...out); + } else { + const target = data as Record; + const source = out as Record; + for (const key of Reflect.ownKeys(target)) { + if (!Object.hasOwn(source, key)) Reflect.deleteProperty(target, key); + } + for (const key of Reflect.ownKeys(source)) target[key] = source[key]; + } + return data; + }; + } else { + callable = (data: unknown, path?: readonly PropertyKey[]): unknown => validate(clone(data), path); + } + } + + // Root defaults materialize for absent input: `schema(undefined)` and the + // Standard Schema boundary (`~standard.validate(undefined)`) both yield the + // default instead of a base-IR rejection. Factories run per call; static + // defaults reuse the precomputed validated output (mutable statics are + // rejected at `.default()` time). + if (meta.hasDefault === true) { + const inner = callable; + const value = meta.defaultValue; + callable = (data: unknown, path?: readonly PropertyKey[]): unknown => { + if (data !== undefined) return inner(data, path); + if (meta.hasDefaultOutput === true) return meta.defaultOutput; + return inner(typeof value === "function" ? (value as () => unknown)() : value, path); + }; + } const self = callable as InternalType; self[IR_BRAND] = true; @@ -742,13 +1288,12 @@ function makeType(ir: IR, steps: Step[], meta: TypeMeta): unknown { self.hasSteps = steps.length > 0; self.hasDefault = meta.hasDefault === true; self.defaultValue = meta.defaultValue; - self.description = meta.description; - self.errorConfig = meta.errorConfig; + self.defaultOutput = meta.defaultOutput; + self.hasDefaultOutput = meta.hasDefaultOutput === true; + self.errorConfig = meta.errorConfig ?? ir.cfg; + self.clone = meta.clone; self.run = callable; - // Walk steps backwards: the last pipe decides the output state — `.to` - // gives a known output IR, a bare pipe makes the output opaque; narrow and - // filter steps never change the value and are skipped. No pipes at all - // means the structural IR is the output. + self.$ = ARK_COMPAT_SCOPE; for (let i = steps.length - 1; i >= 0; i--) { const step = steps[i]; if (step.kind !== "pipe") continue; @@ -760,12 +1305,442 @@ function makeType(ir: IR, steps: Step[], meta: TypeMeta): unknown { return self; } +function appendPipes( + source: InternalType, + pipes: readonly unknown[], + catchErrors: boolean, + forcePipeline = false, +): InternalType { + let schema = source; + for (const candidate of pipes) { + const isSchema = + (typeof candidate === "function" || (typeof candidate === "object" && candidate !== null)) && + IR_BRAND in candidate; + if (isSchema) { + const target = candidate as InternalType; + if ( + !forcePipeline && + schema[kSteps].length === 0 && + !target.hasSteps && + !hasMorph(schema.ir) && + !hasMorph(target.ir) + ) { + schema = makeType(intersect(schema.ir, target.ir), [], metaOf(schema)); + continue; + } + const out = target.opaqueOutput ? undefined : (target.stepOut ?? target.ir); + schema = makeType( + schema.ir, + [...schema[kSteps], { kind: "pipe", fn: value => target.run(value), out, try: catchErrors }], + metaOf(schema), + ); + continue; + } + if (typeof candidate !== "function") throw new OmpTypeError("pipe operands must be functions or Types"); + schema = makeType( + schema.ir, + [...schema[kSteps], { kind: "pipe", fn: candidate as Step["fn"], try: catchErrors }], + metaOf(schema), + ); + } + return inheritScope(source, schema); +} + +function projectIO(ir: IR, io: "in" | "out"): IR { + switch (ir.k) { + case "morph": + return projectIO(io === "in" || ir.out === undefined ? ir.input : ir.out, io); + case "sub": + if (io === "out" && ir.schema.opaqueOutput) return { k: "unknown" }; + return projectIO(io === "out" ? (ir.schema.stepOut ?? ir.schema.ir) : ir.schema.ir, io); + case "array": + return { ...ir, el: projectIO(ir.el, io) }; + case "tuple": + return { + ...ir, + prefix: ir.prefix.map(item => ({ + ...item, + opt: io === "in" ? item.opt || item.hasDefault === true : item.opt && !item.hasDefault, + val: projectIO(item.val, io), + hasDefault: false, + def: undefined, + defFactory: false, + defValidated: false, + })), + variadic: ir.variadic === undefined ? undefined : projectIO(ir.variadic, io), + postfix: ir.postfix.map(item => projectIO(item, io)), + }; + case "object": + return { + ...ir, + props: ir.props.map(prop => ({ + ...prop, + opt: io === "in" ? prop.opt || prop.hasDefault === true : prop.opt && !prop.hasDefault, + val: projectIO(prop.val, io), + hasDefault: false, + def: undefined, + defFactory: false, + defValidated: false, + })), + index: ir.index === undefined ? undefined : projectIO(ir.index, io), + symbolIndex: ir.symbolIndex === undefined ? undefined : projectIO(ir.symbolIndex, io), + patternIndexes: ir.patternIndexes?.map(index => ({ + key: projectIO(index.key, io), + val: projectIO(index.val, io), + })), + extras: ir.extras === "delete" ? (io === "in" ? "keep" : "reject") : ir.extras, + }; + case "union": + case "intersection": + return { ...ir, members: ir.members.map(member => projectIO(member, io)) }; + case "refine": + return { ...ir, base: projectIO(ir.base, io) }; + case "alias": + return { ...ir, resolve: () => projectIO(ir.resolve(), io) }; + default: + return ir; + } +} + +function morphIdentities(ir: IR, identities: unknown[] = [], seen = new Set()): unknown[] { + if (seen.has(ir)) return identities; + seen.add(ir); + switch (ir.k) { + case "morph": + identities.push( + ir.out === undefined + ? ir.fn + : `declared:${expressionOf(projectIO(ir.input, "in"))}=>${expressionOf(projectIO(ir.out, "out"))}`, + ); + morphIdentities(ir.input, identities, seen); + if (ir.out !== undefined) morphIdentities(ir.out, identities, seen); + break; + case "sub": { + const schema = ir.schema as InternalType; + for (const step of schema[kSteps]) if (step.kind === "pipe") identities.push(step.fn); + morphIdentities(schema.ir, identities, seen); + break; + } + case "array": + morphIdentities(ir.el, identities, seen); + break; + case "tuple": + for (const item of ir.prefix) morphIdentities(item.val, identities, seen); + if (ir.variadic !== undefined) morphIdentities(ir.variadic, identities, seen); + for (const item of ir.postfix) morphIdentities(item, identities, seen); + break; + case "object": + if (ir.extras === "delete") identities.push(ir); + for (const prop of ir.props) morphIdentities(prop.val, identities, seen); + if (ir.index !== undefined) morphIdentities(ir.index, identities, seen); + if (ir.symbolIndex !== undefined) morphIdentities(ir.symbolIndex, identities, seen); + for (const index of ir.patternIndexes ?? []) morphIdentities(index.val, identities, seen); + break; + case "union": + case "intersection": + for (const member of ir.members) morphIdentities(member, identities, seen); + break; + case "refine": + morphIdentities(ir.base, identities, seen); + break; + case "alias": + morphIdentities(ir.resolve(), identities, seen); + break; + } + return identities; +} + +function assertDeterminateMorphUnions(ir: IR, seen = new Set()): void { + if (seen.has(ir)) return; + seen.add(ir); + if (ir.k === "union") { + for (let leftIndex = 0; leftIndex < ir.members.length; leftIndex++) { + const left = ir.members[leftIndex]; + const leftMorphs = morphIdentities(left); + for (let rightIndex = leftIndex + 1; rightIndex < ir.members.length; rightIndex++) { + const right = ir.members[rightIndex]; + const rightMorphs = morphIdentities(right); + if (leftMorphs.length === 0 && rightMorphs.length === 0) continue; + if ( + leftMorphs.length === rightMorphs.length && + leftMorphs.every((identity, index) => identity === rightMorphs[index]) + ) { + continue; + } + // Unwrap one alias level eagerly: the disjointness probe relies on + // intersect() throwing, and deferred alias intersections resolve lazily. + let leftInput = projectIO(left, "in"); + let rightInput = projectIO(right, "in"); + if (leftInput.k === "alias") leftInput = leftInput.resolve(); + if (rightInput.k === "alias") rightInput = rightInput.resolve(); + if (leftInput.k === "object" && rightInput.k === "object") { + const leftKeys = new Set(leftInput.props.map(prop => prop.key)); + const rightKeys = new Set(rightInput.props.map(prop => prop.key)); + const leftRejectsRequiredRight = + leftInput.extras === "reject" && + rightInput.props.some(prop => !prop.opt && !prop.hasDefault && !leftKeys.has(prop.key)); + const rightRejectsRequiredLeft = + rightInput.extras === "reject" && + leftInput.props.some(prop => !prop.opt && !prop.hasDefault && !rightKeys.has(prop.key)); + if (leftRejectsRequiredRight || rightRejectsRequiredLeft) continue; + } + try { + intersect(leftInput, rightInput); + } catch (error) { + if (error instanceof OmpTypeError) continue; + throw error; + } + throw new OmpTypeError("an unordered union with overlapping morph inputs is indeterminate"); + } + } + } + switch (ir.k) { + case "alias": + assertDeterminateMorphUnions(ir.resolve(), seen); + break; + case "morph": + assertDeterminateMorphUnions(ir.input, seen); + if (ir.out !== undefined) assertDeterminateMorphUnions(ir.out, seen); + break; + case "sub": + assertDeterminateMorphUnions(ir.schema.ir, seen); + break; + case "array": + assertDeterminateMorphUnions(ir.el, seen); + break; + case "tuple": + for (const item of ir.prefix) assertDeterminateMorphUnions(item.val, seen); + if (ir.variadic !== undefined) assertDeterminateMorphUnions(ir.variadic, seen); + for (const item of ir.postfix) assertDeterminateMorphUnions(item, seen); + break; + case "object": + for (const prop of ir.props) assertDeterminateMorphUnions(prop.val, seen); + if (ir.index !== undefined) assertDeterminateMorphUnions(ir.index, seen); + if (ir.symbolIndex !== undefined) assertDeterminateMorphUnions(ir.symbolIndex, seen); + for (const index of ir.patternIndexes ?? []) assertDeterminateMorphUnions(index.val, seen); + break; + case "union": + case "intersection": + for (const member of ir.members) assertDeterminateMorphUnions(member, seen); + break; + case "refine": + assertDeterminateMorphUnions(ir.base, seen); + break; + } +} + +function getPathIR(ir: IR, requestedKey: unknown): IR { + let key = requestedKey; + if ((typeof key === "function" || (typeof key === "object" && key !== null)) && IR_BRAND in key) { + const keySchema = key as InternalType; + if (keySchema.ir.k === "symbol") key = Symbol.for("omptype.index"); + else { + const arrayIndex: unknown = type.arrayIndex; + if (keySchema === arrayIndex) key = 0; + else { + throw new OmpTypeError( + `${keySchema.expression} is not allowed as an array or object index; use a concrete property key`, + ); + } + } + } + if (typeof key !== "string" && typeof key !== "number" && typeof key !== "symbol") { + throw new OmpTypeError(`get keys must be strings, numbers, or symbols`); + } + if (ir.k === "alias") return getPathIR(ir.resolve(), key); + if (ir.k === "sub") return getPathIR(ir.schema.ir, key); + if (ir.k === "union") { + return unionOf(ir.members.map(member => getPathIR(member, key))); + } + if (ir.k === "array") { + const index = typeof key === "number" ? key : typeof key === "string" && /^\d+$/.test(key) ? Number(key) : -1; + if (!Number.isSafeInteger(index) || index < 0) throw new OmpTypeError(`key ${String(key)} is not declared`); + return unionOf([ir.el, { k: "undefined" }]); + } + if (ir.k === "tuple") { + const index = typeof key === "number" ? key : typeof key === "string" && /^\d+$/.test(key) ? Number(key) : -1; + if (!Number.isSafeInteger(index) || index < 0) throw new OmpTypeError(`key ${String(key)} is not declared`); + if (index < ir.prefix.length) { + const item = ir.prefix[index]; + return item.opt ? unionOf([{ k: "undefined" }, item.val]) : item.val; + } + if (ir.variadic === undefined) throw new OmpTypeError(`key ${String(key)} is not declared`); + return unionOf([{ k: "undefined" }, ir.variadic, ...ir.postfix]); + } + if (ir.k === "undefined") return ir; + if (ir.k !== "object") throw new OmpTypeError("get requires an object schema"); + + const matches: IR[] = []; + const prop = ir.props.find(candidate => candidate.key === String(key)); + if (prop !== undefined) matches.push(prop.val); + if (typeof key === "string") { + if (ir.index !== undefined) matches.push(ir.index); + for (const index of ir.patternIndexes ?? []) { + if (!(walk(index.key, key) instanceof OmpErrors)) matches.push(index.val); + } + } else if (typeof key === "symbol" && ir.symbolIndex !== undefined) { + matches.push(ir.symbolIndex); + } + if (matches.length === 0) throw new OmpTypeError(`key ${String(key)} is not declared`); + const value = matches.reduce((left, right) => intersect(left, right)); + return prop !== undefined && !prop.opt ? value : unionOf([value, { k: "undefined" }]); +} + +function unionOf(members: IR[]): IR { + const flattened = members.flatMap(member => (member.k === "union" ? member.members : [member])); + return flattened.length === 1 ? flattened[0] : { k: "union", members: flattened }; +} + +function expressionOf(ir: IR, ancestors = new Set()): string { + if (ancestors.has(ir)) return ir.k === "alias" ? ir.name : ir.k; + const nextAncestors = new Set(ancestors); + nextAncestors.add(ir); + const child = (node: IR): string => expressionOf(node, nextAncestors); + switch (ir.k) { + case "alias": + return child(ir.resolve()); + case "sub": + return child(ir.schema.ir); + case "unknown": + case "null": + case "undefined": + case "boolean": + case "bigint": + case "symbol": + case "never": + return ir.k; + case "anyobject": + return "object"; + case "string": + return "string"; + case "number": + return ir.divisor !== undefined ? `number % ${ir.divisor}` : ir.int ? "number % 1" : "number"; + case "lit": + return typeof ir.v === "string" ? JSON.stringify(ir.v) : String(ir.v); + case "array": { + const element = child(ir.el); + return `${ir.el.k === "union" || ir.el.k === "intersection" ? `(${element})` : element}[]`; + } + case "tuple": { + const items = ir.prefix.map(item => { + const value = child(item.val); + if (item.hasDefault) return `${value} = ${child({ k: "lit", v: item.def })}`; + return `${value}${item.opt ? "?" : ""}`; + }); + if (ir.variadic !== undefined) items.push(`...${child(ir.variadic)}[]`); + items.push(...ir.postfix.map(child)); + return `[${items.join(", ")}]`; + } + case "object": { + const properties = ir.props.map(prop => `${String(prop.key)}${prop.opt ? "?" : ""}: ${child(prop.val)}`); + if (ir.index !== undefined) properties.unshift(`[string]: ${child(ir.index)}`); + if (ir.symbolIndex !== undefined) properties.unshift(`[symbol]: ${child(ir.symbolIndex)}`); + return `{ ${properties.join(", ")} }`; + } + case "union": + return [...new Set(ir.members.map(child))].join(" | "); + case "intersection": + return ir.members.map(child).join(" & "); + case "refine": + return child(ir.base); + case "morph": + return `(In: ${child(ir.input)}) => Out<${child(ir.out ?? { k: "unknown" })}>`; + case "instance": + return ir.ctor.name || "object"; + } +} + +function arkJsonOf(ir: IR, ancestors = new Set()): unknown { + if (ancestors.has(ir)) return ir.k === "alias" ? { alias: ir.name } : { cyclic: ir.k }; + const nextAncestors = new Set(ancestors); + nextAncestors.add(ir); + const child = (node: IR): unknown => arkJsonOf(node, nextAncestors); + switch (ir.k) { + case "alias": + return child(ir.resolve()); + case "sub": + return child(ir.schema.ir); + case "lit": + return { + unit: ir.v === undefined ? "undefined" : typeof ir.v === "bigint" ? `${ir.v}n` : ir.v, + }; + case "null": + return { unit: null }; + case "undefined": + return { unit: "undefined" }; + case "boolean": + return [{ unit: false }, { unit: true }]; + case "union": + case "intersection": + return ir.members.map(child); + case "array": + return { proto: "Array", sequence: child(ir.el) }; + case "tuple": + return { + proto: "Array", + sequence: { + prefix: ir.prefix.map(item => child(item.val)), + ...(ir.variadic === undefined ? {} : { variadic: child(ir.variadic) }), + ...(ir.postfix.length === 0 ? {} : { postfix: ir.postfix.map(child) }), + }, + }; + case "object": + return { + domain: "object", + required: ir.props + .filter(prop => !prop.opt && !prop.hasDefault) + .map(prop => ({ key: prop.key, value: child(prop.val) })), + optional: ir.props + .filter(prop => prop.opt || prop.hasDefault) + .map(prop => ({ + key: prop.key, + value: child(prop.val), + ...(prop.hasDefault + ? { + default: + prop.defFactory && typeof prop.def === "function" + ? `$ark.${prop.def.name || "default"}` + : prop.def, + } + : {}), + })), + }; + case "refine": + return child(ir.base); + case "morph": + return { in: child(ir.input), ...(ir.out === undefined ? {} : { declaredOut: child(ir.out) }) }; + case "instance": + return { proto: ir.ctor.name }; + case "anyobject": + return { domain: "object" }; + default: + return ir.k; + } +} + type ObjectIR = Extract; function requireObject(ir: IR, operation: string): ObjectIR { if (ir.k !== "object") throw new OmpTypeError(`${operation} requires an object schema`); return ir; } +function selectObjectProps(ir: IR, keys: readonly PropertyKey[], keepSelected: boolean, operation: string): ObjectIR { + const object = requireObject(ir, operation); + const selected = new Set(keys); + for (const key of selected) { + if (!object.props.some(prop => prop.key === key)) throw new OmpTypeError(`key ${String(key)} does not exist`); + } + return { ...object, props: object.props.filter(prop => selected.has(prop.key) === keepSelected) }; +} + +function setObjectOptionality(ir: IR, optional: boolean, operation: string): ObjectIR { + const object = requireObject(ir, operation); + return { ...object, props: object.props.map(prop => ({ ...prop, opt: optional })) }; +} + +function mergeObjectDefinition(ir: IR, definition: unknown, resolve?: AliasResolver): ObjectIR { + return mergeObjects(requireObject(ir, "merge"), requireObject(parseDef(definition, resolve), "merge")); +} function propertyFromIR(prop: PropIR): TypeProperty { return { @@ -786,7 +1761,7 @@ function propertyToIR(property: TypeProperty): PropIR { } const hasDefault = Object.hasOwn(property, "default"); return { - key: String(property.key), + key: property.key, opt: property.kind === "optional", val: embed(property.value), ...(hasDefault @@ -795,13 +1770,22 @@ function propertyToIR(property: TypeProperty): PropIR { }; } +function acceptsDateIR(ir: IR): boolean { + if (ir.k === "instance") return ir.ctor === Date; + if (ir.k === "refine") return acceptsDateIR(ir.base); + if (ir.k === "union") return ir.members.every(acceptsDateIR); + return false; +} + function dateRefinement( schema: InternalType, - bound: Date, + timestamp: number, relation: string, predicate: (value: number) => boolean, ): InternalType { - if (!Number.isFinite(bound.valueOf())) throw new OmpTypeError("date bound must be valid"); + if (!Number.isFinite(timestamp)) throw new OmpTypeError("date bound must be valid"); + if (!acceptsDateIR(schema.ir)) throw new OmpTypeError("date bounds require a Date type"); + const bound = new Date(timestamp); return makeType( { k: "refine", @@ -840,6 +1824,11 @@ function selectNodes(root: IR, kind: string): readonly SelectedNode[] { case "object": for (const prop of node.props) visit(prop.val); if (node.index !== undefined) visit(node.index); + if (node.symbolIndex !== undefined) visit(node.symbolIndex); + for (const index of node.patternIndexes ?? []) { + visit(index.key); + visit(index.val); + } break; case "union": case "intersection": @@ -872,10 +1861,22 @@ function mergeObjects(left: ObjectIR, right: ObjectIR): ObjectIR { k: "object", props, index: right.index ?? left.index, + symbolIndex: right.symbolIndex ?? left.symbolIndex, + patternIndexes: + left.patternIndexes === undefined && right.patternIndexes === undefined + ? undefined + : [...(left.patternIndexes ?? []), ...(right.patternIndexes ?? [])], extras: right.extras === "keep" ? left.extras : right.extras, }; } +function withShallowExtras(ir: IR, extras: ObjectIR["extras"]): IR { + if (ir.k === "object") return { ...ir, extras }; + if (ir.k === "union") return { ...ir, members: ir.members.map(member => withShallowExtras(member, extras)) }; + if (ir.k === "alias") return withShallowExtras(ir.resolve(), extras); + throw new OmpTypeError("onUndeclaredKey requires an object schema"); +} + function withDeepExtras(ir: IR, extras: ObjectIR["extras"]): IR { switch (ir.k) { case "object": @@ -884,6 +1885,11 @@ function withDeepExtras(ir: IR, extras: ObjectIR["extras"]): IR { extras, props: ir.props.map(prop => ({ ...prop, val: withDeepExtras(prop.val, extras) })), index: ir.index === undefined ? undefined : withDeepExtras(ir.index, extras), + symbolIndex: ir.symbolIndex === undefined ? undefined : withDeepExtras(ir.symbolIndex, extras), + patternIndexes: ir.patternIndexes?.map(index => ({ + key: withDeepExtras(index.key, extras), + val: withDeepExtras(index.val, extras), + })), }; case "array": return { ...ir, el: withDeepExtras(ir.el, extras) }; @@ -910,14 +1916,111 @@ function withDeepExtras(ir: IR, extras: ObjectIR["extras"]): IR { } } +function intersectTupleWithArray(tuple: TupleIR, array: Extract): IR { + if (array.min !== undefined || array.max !== undefined) { + return { k: "intersection", members: [tuple, array] }; + } + return { + ...tuple, + prefix: tuple.prefix.map(item => ({ ...item, val: intersect(item.val, array.el) })), + variadic: tuple.variadic === undefined ? undefined : intersect(tuple.variadic, array.el), + postfix: tuple.postfix.map(item => intersect(item, array.el)), + }; +} + +function intersectTuples(left: TupleIR, right: TupleIR): IR { + if ( + left.postfix.length !== 0 || + right.postfix.length !== 0 || + left.prefix.some(item => item.hasDefault) || + right.prefix.some(item => item.hasDefault) + ) { + return { k: "intersection", members: [left, right] }; + } + const leftRequired = left.prefix.filter(item => !item.opt).length; + const rightRequired = right.prefix.filter(item => !item.opt).length; + const minimum = Math.max(leftRequired, rightRequired); + const leftMaximum = left.variadic === undefined ? left.prefix.length : Number.POSITIVE_INFINITY; + const rightMaximum = right.variadic === undefined ? right.prefix.length : Number.POSITIVE_INFINITY; + const maximum = Math.min(leftMaximum, rightMaximum); + if (minimum > maximum) throw new OmpTypeError("tuple length intersection is unsatisfiable"); + + const prefixLength = Number.isFinite(maximum) ? maximum : Math.max(left.prefix.length, right.prefix.length); + const prefix: TupleIR["prefix"] = []; + for (let index = 0; index < prefixLength; index++) { + const leftItem = left.prefix[index]; + const rightItem = right.prefix[index]; + const leftNode = leftItem?.val ?? left.variadic; + const rightNode = rightItem?.val ?? right.variadic; + if (leftNode === undefined || rightNode === undefined) break; + const required = (leftItem !== undefined && !leftItem.opt) || (rightItem !== undefined && !rightItem.opt); + try { + prefix.push({ val: intersect(leftNode, rightNode), opt: !required }); + } catch (error) { + if (required || !(error instanceof OmpTypeError)) throw error; + break; + } + } + const variadic = + leftMaximum === Number.POSITIVE_INFINITY && rightMaximum === Number.POSITIVE_INFINITY + ? intersect(left.variadic!, right.variadic!) + : undefined; + return { k: "tuple", prefix, variadic, postfix: [] }; +} + +const kIntersections = Symbol("omptype.intersections"); + +/** Deferred alias-pair intersections cached on the left node; cyclic references resolve to one node. */ +interface AliasIntersections { + [kIntersections]?: WeakMap; +} + /** Intersect two IR nodes, rejecting statically disjoint domains. */ function intersect(a: IR, b: IR): IR { - if (a.k === "alias") return intersect(a.resolve(), b); - if (b.k === "alias") return intersect(a, b.resolve()); + if (a.k === "alias" || b.k === "alias") { + // Defer through a lazy alias so cyclic references terminate: revisiting + // the same pair while it is being resolved returns the same node instead + // of recursing forever. + const target = a as IR & AliasIntersections; + target[kIntersections] ??= new WeakMap(); + const cache = target[kIntersections]; + const existing = cache.get(b); + if (existing !== undefined) return existing; + let resolved: IR | undefined; + const reference: IR = { + k: "alias", + name: a.k === "alias" ? a.name : b.k === "alias" ? b.name : "intersection", + resolve: () => + (resolved ??= intersectResolved(a.k === "alias" ? a.resolve() : a, b.k === "alias" ? b.resolve() : b)), + }; + cache.set(b, reference); + return reference; + } + return intersectResolved(a, b); +} + +function intersectResolved(a: IR, b: IR): IR { if (a.k === "never" || b.k === "never") throw new OmpTypeError("intersection with never is unsatisfiable"); if (a.k === "unknown") return b; if (b.k === "unknown") return a; if (a === b) return a; + if (a.k === "morph" && b.k === "morph") { + if (a.fn !== b.fn || a.out !== b.out) { + throw new OmpTypeError("intersection of distinct morphs is indeterminate"); + } + return { ...a, input: intersect(a.input, b.input) }; + } + if (a.k === "morph") return { ...a, input: intersect(a.input, b) }; + if (b.k === "morph") return { ...b, input: intersect(a, b.input) }; + if (a.k === "sub" && a.schema.hasSteps) { + if (b.k === "sub" && b.schema.hasSteps) { + if (a.schema === b.schema) return a; + throw new OmpTypeError("intersection of distinct morphs is indeterminate"); + } + const schema = a.schema as InternalType; + return embed(makeType(intersect(schema.ir, b), schema[kSteps], metaOf(schema))); + } + if (b.k === "sub" && b.schema.hasSteps) return intersect(b, a); if (a.k === "union" || b.k === "union") { const union = a.k === "union" ? a : b.k === "union" ? b : undefined; if (union === undefined) throw new OmpTypeError("union intersection invariant failed"); @@ -946,7 +2049,26 @@ function intersect(a: IR, b: IR): IR { if (index < 0) props.push(bp); else { const ap = props[index]; - props[index] = { ...ap, opt: ap.opt && bp.opt, val: intersect(ap.val, bp.val) }; + const required = (!ap.opt && !ap.hasDefault) || (!bp.opt && !bp.hasDefault); + if (ap.hasDefault && bp.hasDefault && !Object.is(ap.def, bp.def)) { + throw new OmpTypeError( + `ParseError: Invalid intersection of default values ${String(ap.def)} & ${String(bp.def)}`, + ); + } + const defaulted = required ? undefined : ap.hasDefault ? ap : bp.hasDefault ? bp : undefined; + props[index] = { + key: ap.key, + opt: ap.opt && bp.opt, + val: intersect(ap.val, bp.val), + ...(defaulted + ? { + def: defaulted.def, + defFactory: defaulted.defFactory, + hasDefault: true, + defValidated: defaulted.defValidated, + } + : {}), + }; } } const extras = @@ -995,12 +2117,21 @@ function intersect(a: IR, b: IR): IR { } return { k: "array", el: intersect(a.el, b.el), min, max }; } + if (a.k === "tuple" && b.k === "tuple") return intersectTuples(a, b); + if (a.k === "tuple" && b.k === "array") return intersectTupleWithArray(a, b); + if (a.k === "array" && b.k === "tuple") return intersectTupleWithArray(b, a); if (a.k === "instance" && b.k === "instance") { if (a.ctor === b.ctor || a.ctor.prototype instanceof b.ctor) return a; if (b.ctor.prototype instanceof a.ctor) return b; throw new OmpTypeError(`intersection of ${a.expected} and ${b.expected} is unsatisfiable`); } if (a.k === b.k && ["null", "undefined", "boolean", "bigint", "symbol", "anyobject"].includes(a.k)) return a; + if ( + (a.k === "object" && (b.k === "array" || b.k === "tuple")) || + (b.k === "object" && (a.k === "array" || a.k === "tuple")) + ) { + return { k: "intersection", members: [a, b] }; + } const leftDomain = domainOf(a); const rightDomain = domainOf(b); if (leftDomain !== undefined && rightDomain !== undefined && leftDomain !== rightDomain) { @@ -1012,6 +2143,151 @@ function intersect(a: IR, b: IR): IR { return { k: "intersection", members }; } +/** Reduce parsed unions/intersections to their observable semantic form. */ +function normalizeIR(ir: IR): IR { + switch (ir.k) { + case "intersection": { + const members = ir.members.map(normalizeIR); + if (members.length === 0) return { k: "unknown" }; + return members.slice(1).reduce(intersect, members[0]); + } + case "union": { + const members: IR[] = []; + let changed = false; + for (let index = 0; index < ir.members.length; index++) { + const original = ir.members[index]; + const member = normalizeIR(original); + changed ||= member !== original; + if (member.k === "union") { + members.push(...member.members); + changed = true; + } else if (member.k === "never") { + changed = true; + } else if (member.k === "unknown") { + return { k: "unknown" }; + } else { + members.push(member); + } + } + if ( + members.every( + member => + member.k === "lit" && + (member.v === null || (typeof member.v !== "object" && typeof member.v !== "function")), + ) + ) { + const pruned: IR[] = []; + for (const member of members) { + if (member.k === "lit" && pruned.some(candidate => candidate.k === "lit" && candidate.v === member.v)) { + changed = true; + } else { + pruned.push(member); + } + } + if (pruned.length === 0) return { k: "never" }; + if (pruned.length === 1) return pruned[0]; + if (pruned.length === 2 && pruned.every(member => member.k === "lit" && typeof member.v === "boolean")) { + return { k: "boolean" }; + } + return changed ? { ...ir, members: pruned } : ir; + } + const pruned = members.filter( + (member, index) => + !members.some( + (candidate, candidateIndex) => + candidateIndex !== index && + !hasMorph(member) && + !hasMorph(candidate) && + isSubtype(member, candidate) && + (!isSubtype(candidate, member) || candidateIndex < index), + ), + ); + if (pruned.length === 0) return { k: "never" }; + if (pruned.length === 1) return pruned[0]; + if (pruned.length === 2 && pruned.every(member => member.k === "lit" && typeof member.v === "boolean")) { + return { k: "boolean" }; + } + if ( + !changed && + !pruned.some(member => member.k === "alias") && + pruned.length === ir.members.length && + pruned.every((member, index) => member === ir.members[index]) + ) { + return ir; + } + return { ...ir, members: pruned }; + } + case "array": { + const element = normalizeIR(ir.el); + return element === ir.el && ir.el.k !== "alias" ? ir : { ...ir, el: element }; + } + case "tuple": { + const prefix = ir.prefix.map(item => { + const value = normalizeIR(item.val); + return value === item.val ? item : { ...item, val: value }; + }); + const variadic = ir.variadic === undefined ? undefined : normalizeIR(ir.variadic); + const postfix = ir.postfix.map(normalizeIR); + if ( + !ir.prefix.some(item => item.val.k === "alias") && + ir.variadic?.k !== "alias" && + !ir.postfix.some(item => item.k === "alias") && + prefix.every((item, index) => item === ir.prefix[index]) && + variadic === ir.variadic && + postfix.every((item, index) => item === ir.postfix[index]) + ) { + return ir; + } + return { ...ir, prefix, variadic, postfix }; + } + case "object": { + let props: PropIR[] | undefined; + for (let index = 0; index < ir.props.length; index++) { + const prop = ir.props[index]; + const value = normalizeIR(prop.val); + if (value === prop.val) continue; + props ??= [...ir.props]; + props[index] = { ...prop, val: value }; + } + const index = ir.index === undefined ? undefined : normalizeIR(ir.index); + const symbolIndex = ir.symbolIndex === undefined ? undefined : normalizeIR(ir.symbolIndex); + const patternIndexes = ir.patternIndexes?.map(pattern => { + const key = normalizeIR(pattern.key); + const val = normalizeIR(pattern.val); + return key === pattern.key && val === pattern.val ? pattern : { key, val }; + }); + if ( + props === undefined && + !ir.props.some(prop => prop.val.k === "alias") && + ir.index?.k !== "alias" && + ir.symbolIndex?.k !== "alias" && + !ir.patternIndexes?.some(pattern => pattern.key.k === "alias" || pattern.val.k === "alias") && + index === ir.index && + symbolIndex === ir.symbolIndex && + patternIndexes?.every((pattern, patternIndex) => pattern === ir.patternIndexes?.[patternIndex]) !== false + ) { + return ir; + } + return { ...ir, props: props ?? ir.props, index, symbolIndex, patternIndexes }; + } + case "refine": { + const base = normalizeIR(ir.base); + return base === ir.base && ir.base.k !== "alias" ? ir : { ...ir, base }; + } + case "morph": { + const input = normalizeIR(ir.input); + const out = ir.out === undefined ? undefined : normalizeIR(ir.out); + return input === ir.input && out === ir.out && ir.input.k !== "alias" && ir.out?.k !== "alias" + ? ir + : { ...ir, input, out }; + } + case "alias": + return ir; + default: + return ir; + } +} + function domainOf(ir: IR): string | undefined { switch (ir.k) { case "null": @@ -1186,18 +2462,241 @@ function withLengthBound(ir: IR, side: "min" | "max", bound: number): IR { } throw new OmpTypeError(`cannot apply length bound to ${ir.k}`); } + function withNumericBound(ir: IR, side: "min" | "max", bound: number, exclusive = false): IR { + if (!Number.isFinite(bound)) throw new OmpTypeError("numeric bound must be finite"); if (ir.k === "number") { return side === "min" ? { ...ir, min: bound, xmin: exclusive } : { ...ir, max: bound, xmax: exclusive }; } + if (ir.k === "union") { + return { ...ir, members: ir.members.map(member => withNumericBound(member, side, bound, exclusive)) }; + } throw new OmpTypeError(`cannot apply numeric bound to ${ir.k}`); } +interface GenericParameter { + readonly name: string; + readonly constraintDef?: unknown; +} -/** - * Parse a definition into a callable schema with distinct input/output inference. - */ -export function type(def: def): FluentType, InferDefIn> { - return makeType, InferDefIn>(parseDef(def), EMPTY_STEPS, EMPTY_META); +interface GenericMeta { + readonly parameters: readonly GenericParameter[]; + instantiateIR(arguments_: readonly IR[]): IR; +} + +const GENERIC_META = Symbol("omptype.generic"); + +/** Callable runtime generic returned by `type("", def)` and `type.generic(...)`. */ +export type Generic = (...arguments_: readonly unknown[]) => BaseType; + +interface RuntimeGeneric extends Generic { + readonly [GENERIC_META]: GenericMeta; +} + +/** Schema arguments passed to a callback-bodied runtime generic. */ +export interface GenericArguments { + readonly [name: string]: BaseType; +} + +export interface GenericBuilder { + (definition: (arguments_: GenericArguments) => unknown, hkt?: unknown): Generic; + (definition: unknown, hkt?: unknown): Generic; +} + +function validateGenericParameters(parameters: readonly GenericParameter[]): void { + const names = new Set(); + for (const parameter of parameters) { + if (!/^[A-Za-z_$]\w*$/.test(parameter.name)) { + throw new OmpTypeError(`invalid generic parameter "${parameter.name}"`); + } + if (names.has(parameter.name)) throw new OmpTypeError(`duplicate generic parameter "${parameter.name}"`); + names.add(parameter.name); + } + if (parameters.length === 0) throw new OmpTypeError("generic declarations require at least one parameter"); +} + +function parseGenericParameters(source: string): GenericParameter[] { + const trimmed = source.trim(); + const body = trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; + const parts: string[] = []; + let start = 0; + let depth = 0; + let quote = ""; + for (let index = 0; index < body.length; index++) { + const char = body[index]; + if (quote !== "") { + if (char === quote && body[index - 1] !== "\\") quote = ""; + continue; + } + if (char === "'" || char === '"' || char === "`") quote = char; + else if (char === "<" || char === "(" || char === "[") depth++; + else if (char === ">" || char === ")" || char === "]") depth = Math.max(0, depth - 1); + else if (char === "," && depth === 0) { + parts.push(body.slice(start, index).trim()); + start = index + 1; + } + } + parts.push(body.slice(start).trim()); + const parameters = parts.map(part => { + const constrained = part.match(/^([A-Za-z_$]\w*)\s+extends\s+(.+)$/s); + return constrained === null ? { name: part } : { name: constrained[1], constraintDef: constrained[2].trim() }; + }); + validateGenericParameters(parameters); + return parameters; +} + +function parseGenericDeclaration(source: string): { name: string; parameters: GenericParameter[] } | undefined { + const match = source.trim().match(/^([A-Za-z_$]\w*)\s*(<.*>)$/s); + if (match === null) return undefined; + return { name: match[1], parameters: parseGenericParameters(match[2]) }; +} + +function isRuntimeGeneric(value: unknown): value is RuntimeGeneric { + return typeof value === "function" && GENERIC_META in value; +} + +function genericResolver( + parameters: readonly GenericParameter[], + arguments_: readonly IR[], + outer?: AliasResolver, +): AliasResolver { + const byName = new Map(); + for (let index = 0; index < parameters.length; index++) byName.set(parameters[index].name, arguments_[index]); + const resolve = ((name: string) => byName.get(name) ?? outer?.(name)) as AliasResolver; + resolve.hasGeneric = outer?.hasGeneric; + resolve.generic = outer?.generic; + return resolve; +} + +function parseGenericArgument(definition: unknown, outer?: AliasResolver): IR { + if (outer === undefined) { + try { + return parseDef(definition); + } catch (error) { + if (!(error instanceof OmpTypeError) || !error.message.includes('unknown keyword "this"')) throw error; + } + } + let root: IR | undefined; + const self: IR = { + k: "alias", + name: "this", + resolve: () => { + if (root === undefined || root === self) throw new OmpTypeError('"this" cannot be used as a root definition'); + return root; + }, + }; + const resolve = ((name: string) => (name === "this" ? self : outer?.(name))) as AliasResolver; + if (outer !== undefined) { + resolve.hasGeneric = outer.hasGeneric; + resolve.generic = outer.generic; + } else { + // The retry only exists to serve "this"; parses of this-free member + // strings inside the definition may still share the string cache. + markThisOnlyResolver(resolve); + } + root = parseDef(definition, resolve); + if (root === self) throw new OmpTypeError('"this" cannot be used as a root definition'); + return root; +} + +function genericBodyIR( + parameters: readonly GenericParameter[], + definition: unknown, + arguments_: readonly IR[], + outer?: AliasResolver, +): IR { + const resolve = genericResolver(parameters, arguments_, outer); + const body = + typeof definition === "function" && !(IR_BRAND in definition) + ? definition( + Object.fromEntries( + parameters.map((parameter, index) => [ + parameter.name, + makeType(arguments_[index], EMPTY_STEPS, EMPTY_META) as unknown as BaseType, + ]), + ) as unknown as GenericArguments, + ) + : definition; + return parseDef(body, resolve); +} + +function createRuntimeGeneric( + parameters: readonly GenericParameter[], + definition: unknown, + outer?: AliasResolver, + validateBody = true, +): RuntimeGeneric { + const constraintResolve = ((name: string) => outer?.(name)) as AliasResolver; + constraintResolve.hasGeneric = outer?.hasGeneric; + constraintResolve.generic = outer?.generic; + validateGenericParameters(parameters); + const placeholders = parameters.map(parameter => + parameter.constraintDef === undefined + ? ({ k: "unknown" } as IR) + : parseDef(parameter.constraintDef, constraintResolve), + ); + if (validateBody) genericBodyIR(parameters, definition, placeholders, outer); + const meta: GenericMeta = { + parameters, + instantiateIR(arguments_) { + if (arguments_.length !== parameters.length) { + throw new OmpTypeError(`generic expects ${parameters.length} arguments (received ${arguments_.length})`); + } + for (let index = 0; index < parameters.length; index++) { + const parameter = parameters[index]; + if (parameter.constraintDef === undefined) continue; + const constraint = parseDef(parameter.constraintDef, constraintResolve); + if (!isSubtype(arguments_[index], constraint)) { + throw new OmpTypeError(`${parameter.name} must be assignable to its constraint`); + } + } + return genericBodyIR(parameters, definition, arguments_, outer); + }, + }; + const generic = Object.assign( + (...arguments_: readonly unknown[]) => + makeType( + meta.instantiateIR(arguments_.map(argument => parseGenericArgument(argument, outer))), + EMPTY_STEPS, + EMPTY_META, + ), + { [GENERIC_META]: meta }, + ); + Object.defineProperty(generic, GENERIC_META, { value: meta }); + return generic; +} + +export function type(parameters: `<${string}>`, definition: definition): Generic; +export function type(def: def): FluentType, InferDefIn>; +export function type( + def: SchemaInference | string, + operator: "=>", + morph: (data: input, ctx: NarrowContext) => output, +): FluentType, input>; +export function type( + def: input, + operator: "|>", + out: output, +): FluentType, InferDefIn>; +export function type( + ...definition: expression +): FluentType, InferDefIn>; +export function type(first?: unknown): FluentType | Generic { + // biome-ignore lint/complexity/noArguments: Avoid allocating a rest array for the dominant single-definition call. + const count = arguments.length; + if (count === 2 && typeof first === "string" && first.trimStart().startsWith("<")) { + // biome-ignore lint/complexity/noArguments: The generic path reads its second positional argument without a rest array. + return createRuntimeGeneric(parseGenericParameters(first), arguments[1]); + } + let definition: unknown = first; + if (count !== 1) { + const expression: unknown[] = new Array(count); + for (let index = 0; index < count; index++) { + // biome-ignore lint/complexity/noArguments: Only multi-part expressions pay to materialize an argument array. + expression[index] = arguments[index]; + } + definition = expression; + } + return makeType(parseGenericArgument(definition), EMPTY_STEPS, EMPTY_META); } /** String keyword with a parser that morphs validated text to another output. */ @@ -1308,9 +2807,487 @@ function preformattedKeyword(name: string): PreformattedKeyword { }); } -function caseResolver(value: unknown): (input: unknown) => unknown { +type MatchDefault = + | "assert" + | "never" + | "reject" + | ((input: input, ...args: readonly unknown[]) => output); + +type MatchCaseOutput = { + [key in keyof cases]: cases[key] extends (...args: never[]) => infer output ? output : never; +}[keyof cases]; + +/** A finalized matcher. Like a schema, it returns structured errors unless finalized with `"assert"`. */ +export type Matcher = FluentType & + ((value: value, ...args: readonly unknown[]) => output | OmpErrors); + +/** Fluent first-match parser exposed as `match` and `type.match`. */ +export interface MatchParser { + >( + cases: cases, + ): MatchParser> | Matcher>; + case( + definition: definition, + resolver: (value: InferDef, ...args: readonly unknown[]) => result, + ): MatchParser; + match>( + cases: cases, + ): MatchParser> | Matcher>; + default(fallback: MatchDefault): Matcher; + at(key: key): MatchParser; + at>( + key: key, + cases: cases, + ): MatchParser> | Matcher>; + strings>( + cases: cases, + ): MatchParser> | Matcher>; + in(): MatchParser; + in(definition: definition): MatchParser, output>; +} + +interface MatchBranch { + readonly definition: unknown; + readonly schema: BaseType; + readonly resolve: (input: unknown, ...args: readonly unknown[]) => unknown; +} + +interface MatchState { + readonly parse: (definition: unknown) => BaseType; + readonly branches: readonly MatchBranch[]; + readonly input?: BaseType; + readonly key?: PropertyKey; +} + +function caseResolver(value: unknown): (input: unknown, ...args: readonly unknown[]) => unknown { if (typeof value !== "function") throw new OmpTypeError("match case values must be functions"); - return input => Reflect.apply(value, undefined, [input]); + return (input, ...args) => Reflect.apply(value, undefined, [input, ...args]); +} + +function unionIR(branches: readonly MatchBranch[]): IR { + const members = branches.map(branch => branch.schema.ir); + if (members.length === 0) return { k: "never" }; + if (members.length === 1) return members[0]; + return { k: "union", members }; +} + +function publicMatcher( + state: MatchState, + fallback: MatchDefault, +): Matcher { + const fallbackResolver = typeof fallback === "function" ? caseResolver(fallback) : undefined; + const casesIR = unionIR(state.branches); + let casesSchema: BaseType; + if (state.key === undefined || state.branches.length === 0) { + casesSchema = + state.key === undefined + ? (makeType(casesIR, EMPTY_STEPS, EMPTY_META) as unknown as BaseType) + : state.parse({ [state.key]: "never" }); + } else { + const first = state.branches[0].schema.ir; + if (first.k !== "object") throw new OmpTypeError("match.at cases must define object schemas"); + const propertyKey = String(state.key); + const values: IR[] = []; + for (const branch of state.branches) { + if (branch.schema.ir.k !== "object") throw new OmpTypeError("match.at cases must define object schemas"); + const property = branch.schema.ir.props.find(candidate => candidate.key === propertyKey); + if (property === undefined) throw new OmpTypeError(`match.at case is missing ${propertyKey}`); + values.push(property.val); + } + const value = values.length === 1 ? values[0] : { k: "union" as const, members: values }; + casesSchema = makeType( + { + ...first, + props: first.props.map(property => (property.key === propertyKey ? { ...property, val: value } : property)), + }, + EMPTY_STEPS, + EMPTY_META, + ) as unknown as BaseType; + } + const execute = (input: unknown, args: readonly unknown[]): unknown => { + let matchedInput = input; + if (state.input !== undefined) { + const validated = state.input.run(input); + if (validated instanceof OmpErrors) return validated; + matchedInput = validated; + } + for (const branch of state.branches) { + const matched = branch.schema.run(matchedInput); + if (!(matched instanceof OmpErrors)) return branch.resolve(matched, ...args); + } + if (fallbackResolver !== undefined) return fallbackResolver(matchedInput, ...args); + return casesSchema.run(matchedInput); + }; + const schema = makeType({ k: "unknown" }, [{ kind: "pipe", fn: input => execute(input, []) }], EMPTY_META); + const callable = ((value: unknown, ...args: readonly unknown[]): unknown => { + const result = execute(value, args); + if ((fallback === "assert" || fallback === "never") && result instanceof OmpErrors) { + throw new TraversalError(result); + } + return result; + }) as unknown as InternalType; + Object.assign(callable, schema); + Object.setPrototypeOf(callable, typeMethods); + return callable as unknown as Matcher; +} + +function addMatchCases(state: MatchState, cases: Record): MatchParser { + const branches = [...state.branches]; + let fallback: MatchDefault | undefined; + for (const rawDefinition of Reflect.ownKeys(cases)) { + const value = Reflect.get(cases, rawDefinition); + if (rawDefinition === "default") { + if (value !== "assert" && value !== "never" && value !== "reject" && typeof value !== "function") { + throw new OmpTypeError('match default must be "assert", "never", "reject" or a function'); + } + fallback = value as MatchDefault; + continue; + } + const definition = typeof rawDefinition === "symbol" ? rawDefinition : String(rawDefinition); + const caseDefinition = state.key === undefined ? definition : { [state.key]: definition }; + branches.push({ + definition, + schema: state.parse(caseDefinition), + resolve: caseResolver(value), + }); + } + const next = { ...state, branches }; + return fallback === undefined ? createMatchParser(next) : (publicMatcher(next, fallback) as unknown as MatchParser); +} + +function createMatchParser(state: MatchState): MatchParser { + const parser = ((cases: Record) => addMatchCases(state, cases)) as MatchParser; + parser.case = (definition, resolver) => { + const caseDefinition = state.key === undefined ? definition : { [state.key]: definition }; + return createMatchParser({ + ...state, + branches: [ + ...state.branches, + { + definition, + schema: state.parse(caseDefinition), + resolve: caseResolver(resolver), + }, + ], + }); + }; + parser.match = cases => addMatchCases(state, cases); + parser.default = fallback => publicMatcher(state, fallback); + function at(key: key): MatchParser; + function at>( + key: key, + cases: cases, + ): MatchParser> | Matcher>; + function at(key: PropertyKey, cases?: Record): unknown { + if (state.key !== undefined) throw new OmpTypeError("match.at may only be specified once"); + const next = createMatchParser({ ...state, key }); + return cases === undefined ? next : next.match(cases); + } + parser.at = at; + parser.strings = cases => { + if (state.key === undefined) throw new OmpTypeError("match.strings requires match.at(key)"); + const definitions: Record = {}; + for (const key of Reflect.ownKeys(cases)) { + definitions[key === "default" ? key : JSON.stringify(String(key))] = Reflect.get(cases, key); + } + return addMatchCases(state, definitions); + }; + parser.in = (...args: unknown[]) => { + if (state.input !== undefined) throw new OmpTypeError("match.in may only be specified once"); + return createMatchParser({ + ...state, + ...(args.length === 0 ? {} : { input: state.parse(args[0]) }), + }); + }; + return parser; +} + +/** Build a fluent first-match dispatcher from schema definitions. */ +const matchBuilder: MatchParser = createMatchParser({ + parse: definition => type.raw(definition), + branches: [], +}); + +export { matchBuilder as match }; + +function fnExpression(ir: IR): string { + switch (ir.k) { + case "unknown": + case "null": + case "undefined": + case "boolean": + case "bigint": + case "symbol": + case "never": + case "string": + case "number": + return ir.k; + case "anyobject": + return "object"; + case "lit": + return typeof ir.v === "string" ? JSON.stringify(ir.v) : String(ir.v); + case "union": + return ir.members.map(fnExpression).join(" | "); + case "intersection": + return ir.members.map(fnExpression).join(" & "); + case "array": { + const element = fnExpression(ir.el); + return ir.el.k === "unknown" ? "Array" : `${element.includes(" | ") ? `(${element})` : element}[]`; + } + case "tuple": { + const elements = ir.prefix.map(item => { + const expression = fnExpression(item.val); + if (item.hasDefault) return `${expression} = ${String(item.def)}`; + return item.opt ? `${expression}?` : expression; + }); + if (ir.variadic !== undefined) elements.push(`...${fnExpression({ k: "array", el: ir.variadic })}`); + elements.push(...ir.postfix.map(fnExpression)); + return `[${elements.join(", ")}]`; + } + case "object": + return `{ ${ir.props + .map(property => `${String(property.key)}${property.opt ? "?" : ""}: ${fnExpression(property.val)}`) + .join(", ")} }`; + case "refine": + return fnExpression(ir.base); + case "morph": + return `(In: ${fnExpression(ir.input)}) => To<${fnExpression(ir.out ?? { k: "unknown" })}>`; + case "instance": + return ir.ctor.name || "object"; + case "alias": + return fnExpression(ir.resolve()); + case "sub": { + const schema = ir.schema; + if (!schema.hasSteps) return fnExpression(schema.ir); + return `(In: ${fnExpression(schema.ir)}) => To<${fnExpression(schema.stepOut ?? { k: "unknown" })}>`; + } + } +} + +function normalizeFnParameter(definition: unknown): unknown { + if (typeof definition !== "string") return definition; + const optional = definition.match(/^(.*[^\s])\?$/); + if (optional) return [optional[1], "?"]; + const defaulted = definition.match(/^(.*?)\s*=\s*(.+)$/); + if (defaulted) { + const source = defaulted[2]; + const value = + source === "true" + ? true + : source === "false" + ? false + : source === "null" + ? null + : Number.isNaN(Number(source)) + ? source + : Number(source); + return [defaulted[1], "=", value]; + } + return definition; +} + +function makeFn(resolve?: AliasResolver): FnParser { + function parser( + ...definitions: definitions + ): FnFactory; + function parser(...definitions: readonly unknown[]): unknown { + const marker = definitions.indexOf(":"); + if (marker !== -1 && (marker !== definitions.length - 2 || definitions.lastIndexOf(":") !== marker)) { + throw new OmpTypeError( + '":" must be followed by exactly one return type e.g:\nfn("string", ":", "number")(s => s.length)', + ); + } + const spreadIndexes: number[] = []; + for (let index = 0; index < definitions.length; index++) { + if (definitions[index] === "...") spreadIndexes.push(index); + } + if (spreadIndexes.length > 1) { + const secondSpread = definitions[spreadIndexes[1] + 1]; + if ( + Array.isArray(secondSpread) && + secondSpread.some( + element => typeof element === "string" && (element.endsWith("?") || element.includes("=")), + ) + ) { + throw new OmpTypeError("An optional element may not follow a variadic element"); + } + throw new OmpTypeError("A tuple may have at most one variadic element"); + } + if (spreadIndexes.length === 1 && spreadIndexes[0] + 2 < (marker === -1 ? definitions.length : marker)) { + const preceding = definitions.slice(0, spreadIndexes[0]); + if ( + preceding.some(element => typeof element === "string" && (element.endsWith("?") || /\s=\s/.test(element))) + ) { + throw new OmpTypeError("A postfix required element cannot follow an optional or defaultable element"); + } + } + const parameterDefinitions = (marker === -1 ? definitions : definitions.slice(0, marker)).map( + normalizeFnParameter, + ); + const params = makeType(parseDef(parameterDefinitions, resolve), [], {}); + const returns = + marker === -1 + ? makeType({ k: "unknown" }, [], {}) + : makeType(parseDef(definitions[marker + 1], resolve), [], {}); + const parameterExpression = fnExpression(params.ir); + const returnsExpression = fnExpression(returns.ir); + return (implementation: (...arguments_: readonly unknown[]) => unknown) => { + if (typeof implementation !== "function") throw new OmpTypeError("type.fn requires a function implementation"); + const raw = (...arguments_: readonly unknown[]): unknown => { + const validatedArguments = params.assert(arguments_); + const result = Reflect.apply(implementation, undefined, validatedArguments); + return returns.assert(result); + }; + const typed = raw.bind(undefined) as TypedFunction; + Object.defineProperties(typed, { + name: { value: `bound typed ${implementation.name}`, configurable: true }, + raw: { value: implementation, enumerable: true }, + params: { value: params, enumerable: true }, + returns: { value: returns, enumerable: true }, + expression: { + value: `(${parameterExpression.slice(1, -1)}) => ${returnsExpression}`, + enumerable: true, + }, + }); + return typed; + }; + } + return Object.assign(parser, { raw: parser }); +} +/** Declares a schema output type while preserving its inferred input. */ +export interface DeclaredParser { + type(definition: definition): FluentType>; +} + +/** Fix a schema's externally declared static type without changing its runtime validation. */ +// biome-ignore lint/complexity/noBannedTypes: empty default options object +export function declare(): DeclaredParser { + return { + type: definition => type(definition) as unknown as FluentType>, + }; +} + +function isTypeValue(value: unknown): value is InternalType { + return (typeof value === "function" || (typeof value === "object" && value !== null)) && IR_BRAND in value; +} + +function resolveAlias(ir: IR): IR { + const seen = new Set(); + let current = ir; + while (current.k === "alias" && !seen.has(current)) { + seen.add(current); + current = current.resolve(); + } + return current; +} + +function sameUnionMember(left: IR, right: IR): boolean { + const a = resolveAlias(left); + const b = resolveAlias(right); + if (a.k === "lit" && b.k === "lit") return Object.is(a.v, b.v); + if (a.k !== b.k) return false; + switch (a.k) { + case "unknown": + case "never": + case "null": + case "undefined": + case "boolean": + case "bigint": + case "symbol": + case "anyobject": + return true; + default: + return false; + } +} + +function buildOr(definitions: readonly unknown[], resolve?: AliasResolver): InternalType; +function buildOr(definitions: readonly unknown[], resolve?: AliasResolver): FluentType; +function buildOr(definitions: readonly unknown[], resolve?: AliasResolver): InternalType { + const members: IR[] = []; + const add = (candidate: IR): boolean => { + const resolved = resolveAlias(candidate); + if (resolved.k === "unknown") { + members.length = 0; + members.push(resolved); + return false; + } + if (resolved.k === "never") return true; + if (resolved.k === "union") { + for (const member of resolved.members) { + if (!add(member)) return false; + } + return true; + } + if (!members.some(member => sameUnionMember(member, candidate))) members.push(candidate); + return true; + }; + for (const definition of definitions) { + if (!add(parseDef(definition, resolve))) break; + } + const ir: IR = members.length === 0 ? { k: "never" } : members.length === 1 ? members[0] : { k: "union", members }; + return makeType(ir, [], {}); +} + +function buildAnd(definitions: readonly unknown[], resolve?: AliasResolver): InternalType; +function buildAnd(definitions: readonly unknown[], resolve?: AliasResolver): FluentType; +function buildAnd(definitions: readonly unknown[], resolve?: AliasResolver): InternalType { + if (definitions.length === 0) return makeType({ k: "unknown" }, [], {}); + let ir = parseDef(definitions[0], resolve); + for (let index = 1; index < definitions.length; index++) { + ir = intersect(ir, parseDef(definitions[index], resolve)); + } + return makeType(ir, [], {}); +} + +function requireNaryObject(ir: IR): ObjectIR { + const resolved = resolveAlias(ir); + if (resolved.k !== "object") throw new OmpTypeError("merge requires an object schema"); + return resolved; +} + +function buildMerge(definitions: readonly unknown[], resolve?: AliasResolver): InternalType; +function buildMerge(definitions: readonly unknown[], resolve?: AliasResolver): FluentType; +function buildMerge(definitions: readonly unknown[], resolve?: AliasResolver): InternalType { + if (definitions.length === 0) return makeType({ k: "anyobject" }, [], {}); + let object = requireNaryObject(parseDef(definitions[0], resolve)); + for (let index = 1; index < definitions.length; index++) { + object = mergeObjects(object, requireNaryObject(parseDef(definitions[index], resolve))); + } + return makeType(object, [], {}); +} + +function buildPipe(definitions: readonly unknown[], resolve?: AliasResolver): InternalType; +function buildPipe(definitions: readonly unknown[], resolve?: AliasResolver): FluentType; +function buildPipe(definitions: readonly unknown[], resolve?: AliasResolver): InternalType { + if (definitions.length === 0) return makeType({ k: "unknown" }, [], {}); + const first = definitions[0]; + let schema = + typeof first === "function" && !isTypeValue(first) + ? appendPipes(makeType({ k: "unknown" }, [], {}), [first], false, true) + : isTypeValue(first) + ? first + : makeType(parseDef(first, resolve), [], {}); + for (let index = 1; index < definitions.length; index++) { + const definition = definitions[index]; + const pipe = + typeof definition === "function" && !isTypeValue(definition) + ? definition + : isTypeValue(definition) + ? definition + : makeType(parseDef(definition, resolve), [], {}); + schema = appendPipes(schema, [pipe], false, true); + } + return schema; +} + +function naryStatics(resolve?: AliasResolver) { + return { + or: (...definitions: readonly unknown[]) => buildOr(definitions, resolve), + and: (...definitions: readonly unknown[]) => buildAnd(definitions, resolve), + merge: (...definitions: readonly unknown[]) => buildMerge(definitions, resolve), + pipe: (...definitions: readonly unknown[]) => buildPipe(definitions, resolve), + }; } export namespace type { @@ -1318,6 +3295,34 @@ export namespace type { export const errors = OmpErrors; export type errors = OmpErrors; + /** Build a union from zero or more definitions. */ + export function or( + ...definitions: definitions + ): FluentType, NaryOrInput> { + return buildOr, NaryOrInput>(definitions); + } + + /** Build an intersection from zero or more definitions. */ + export function and( + ...definitions: definitions + ): FluentType, NaryAndInput> { + return buildAnd, NaryAndInput>(definitions); + } + + /** Right-biased object merge over zero or more definitions. */ + export function merge( + ...definitions: definitions + ): FluentType, NaryMergeInput> { + return buildMerge, NaryMergeInput>(definitions); + } + + /** Compose Types, definitions, and morph callbacks from left to right. */ + export function pipe( + ...definitions: definitions + ): FluentType, NaryPipeInput> { + return buildPipe, NaryPipeInput>(definitions); + } + const normalize: NormalizeKeyword = Object.assign(keywordSchema("string.normalize"), { preformatted: keywordSchema("string.normalize.NFC.preformatted"), NFC: preformattedKeyword("string.normalize.NFC"), @@ -1348,29 +3353,32 @@ export namespace type { }); /** String validator and its refinement/morph keyword module. */ - export const string: StringKeyword = Object.assign(makeType({ k: "string" }, [], {}), { - alpha: keywordSchema("string.alpha"), - alphanumeric: keywordSchema("string.alphanumeric"), - base64, - capitalize: preformattedKeyword("string.capitalize"), - creditCard: keywordSchema("string.creditCard"), - date, - digits: keywordSchema("string.digits"), - email: keywordSchema("string.email"), - hex: keywordSchema("string.hex"), - integer: parsedKeyword("string.integer"), - ip, - json: parsedKeyword("string.json"), - lower: preformattedKeyword("string.lower"), - normalize, - numeric: parsedKeyword("string.numeric"), - regex: keywordSchema("string.regex"), - semver: keywordSchema("string.semver"), - trim: preformattedKeyword("string.trim"), - upper: preformattedKeyword("string.upper"), - url: parsedKeyword("string.url"), - uuid, - }); + export const string: StringKeyword = Object.defineProperties( + makeType({ k: "string" }, [], {}), + Object.getOwnPropertyDescriptors({ + alpha: keywordSchema("string.alpha"), + alphanumeric: keywordSchema("string.alphanumeric"), + base64, + capitalize: preformattedKeyword("string.capitalize"), + creditCard: keywordSchema("string.creditCard"), + date, + digits: keywordSchema("string.digits"), + email: keywordSchema("string.email"), + hex: keywordSchema("string.hex"), + integer: parsedKeyword("string.integer"), + ip, + json: parsedKeyword("string.json"), + lower: preformattedKeyword("string.lower"), + normalize, + numeric: parsedKeyword("string.numeric"), + regex: keywordSchema("string.regex"), + semver: keywordSchema("string.semver"), + trim: preformattedKeyword("string.trim"), + upper: preformattedKeyword("string.upper"), + url: parsedKeyword("string.url"), + uuid, + }), + ) as unknown as StringKeyword; /** Runtime parser keyword family. */ export const parse: ParseKeyword = { @@ -1388,6 +3396,18 @@ export namespace type { integer: makeType({ k: "number", int: true }, [], {}), }); + /** Schema-valued key representing any non-negative integer array index. */ + export const arrayIndex = makeType( + { + k: "refine", + base: { k: "string" }, + pred: value => typeof value === "string" && /^(?:0|[1-9]\d*)$/.test(value), + expected: "a non-negative integer string", + }, + [], + {}, + ); + /** Boolean validator. */ export const boolean = makeType({ k: "boolean" }, [], {}); /** Bigint validator. */ @@ -1402,14 +3422,134 @@ export namespace type { export const any = unknown; /** Validator that rejects every value. */ export const never = makeType({ k: "never" }, [], {}); + + /** ArkType's built-in keyword namespace, including invokable utility generics. */ + export const keywords = { + number: { integer: number.integer }, + Map: keywordSchema>("Map"), + Set: keywordSchema>("Set"), + RegExp: keywordSchema("RegExp"), + File: keywordSchema("File"), + Error: keywordSchema("Error"), + // biome-ignore lint/complexity/noBannedTypes: built-in Function keyword + Function: keywordSchema("Function"), + Array: { + liftFrom( + definition: definition, + ): FluentType[], InferDefIn | InferDefIn[]> { + const element = parseDef(definition); + const array: IR = { k: "array", el: element, desc: "an object" }; + return makeType[], InferDefIn | InferDefIn[]>( + { + k: "morph", + input: { k: "union", members: [element, array] }, + fn: value => (globalThis.Array.isArray(value) ? value : [value]), + out: array, + }, + [], + {}, + ); + }, + }, + Record( + key: key, + value: value, + ): FluentType< + Record, PropertyKey>, InferDef>, + Record, PropertyKey>, InferDefIn> + > { + const keyIR = parseDef(key); + if (keyIR.k !== "string" && keyIR.k !== "symbol") { + throw new OmpTypeError("Record key must be assignable to string or symbol"); + } + const valueIR = parseDef(value); + const ir: IR = + keyIR.k === "symbol" + ? { k: "object", props: [], symbolIndex: valueIR, extras: "keep" } + : { k: "object", props: [], index: valueIR, extras: "keep" }; + return makeType< + Record, PropertyKey>, InferDef>, + Record, PropertyKey>, InferDefIn> + >(ir, [], {}); + }, + Partial( + definition: definition, + ): FluentType>, Partial>>> { + return makeType>, Partial>>>( + setObjectOptionality(parseDef(definition), true, "partial"), + [], + {}, + ); + }, + Required( + definition: definition, + ): FluentType>, Required>>> { + return makeType>, Required>>>( + setObjectOptionality(parseDef(definition), false, "required"), + [], + {}, + ); + }, + Pick( + definition: definition, + ...keys: keys + ): FluentType< + Pick, Extract>>, + Pick>, Extract>>> + > { + return makeType< + Pick, Extract>>, + Pick>, Extract>>> + >(selectObjectProps(parseDef(definition), keys, true, "pick"), [], {}); + }, + Omit( + definition: definition, + ...keys: keys + ): FluentType< + Omit, Extract>>, + Omit>, Extract>>> + > { + return makeType< + Omit, Extract>>, + Omit>, Extract>>> + >(selectObjectProps(parseDef(definition), keys, false, "omit"), [], {}); + }, + Merge( + left: left, + right: right, + ): FluentType, InferDef>, MergeTypes, InferDefIn>> { + return makeType, InferDef>, MergeTypes, InferDefIn>>( + mergeObjectDefinition(parseDef(left), right), + [], + {}, + ); + }, + object: { + json: Object.defineProperties(keywordSchema("object.json"), { + stringify: { + value: keywordSchema("object.json.stringify"), + enumerable: true, + }, + }), + }, + unknown: { any: keywordSchema("unknown.any") }, + }; /** Date instance validator. */ // biome-ignore lint/suspicious/noShadowRestrictedNames: ArkType exposes this exact keyword. export const Date = makeType({ k: "instance", ctor: globalThis.Date, expected: "a Date" }, [], {}); /** Validate instances of `ctor`. */ export function instanceOf(ctor: ctor): FluentType> { + if (typeof ctor !== "function" || ctor.prototype === undefined) { + throw new OmpTypeError("instanceof operands must be constructors"); + } const name = Reflect.get(ctor, "name"); - const expected = typeof name === "string" && name.length > 0 ? `an instance of ${name}` : "an instance"; + const expected = + ctor.prototype === Error.prototype + ? "an Error" + : typeof name === "string" && name.length > 0 + ? `an instance of ${name}` + : "an instance"; return makeType>({ k: "instance", ctor, expected }, [], {}); } @@ -1426,28 +3566,38 @@ export namespace type { return makeType(ir, [], {}); } - /** Build a first-match dispatcher from schema-expression keys and a `default` case. */ - export function match>(cases: cases): (value: unknown) => unknown { - const branches: { schema: BaseType; resolve: (value: unknown) => unknown }[] = []; - let fallback: ((value: unknown) => unknown) | undefined; - for (const definition in cases) { - const resolver = caseResolver(cases[definition]); - if (definition === "default") fallback = resolver; - else branches.push({ schema: raw(definition), resolve: resolver }); + /** Enumerate an enum-like object's forward values, excluding numeric reverse mappings. */ + // biome-ignore lint/suspicious/noShadowRestrictedNames: Object.prototype.valueOf method name API + export function valueOf>( + values: values, + ): FluentType { + const members: IR[] = []; + for (const key in values) { + if (/^(?:0|[1-9]\d*)$/.test(key)) continue; + members.push({ k: "lit", v: values[key] }); } - return value => { - for (const branch of branches) { - if (branch.schema.allows(value)) return branch.resolve(value); - } - if (fallback !== undefined) return fallback(value); - throw new OmpTypeError("match requires a matching case or default"); - }; + const ir: IR = + members.length === 0 ? { k: "never" } : members.length === 1 ? members[0] : { k: "union", members }; + return makeType(ir, [], {}); } + + /** Fluent first-match dispatcher, also exported as standalone `match`. */ + export const match: MatchParser = matchBuilder; /** Preserve a definition's literal type while authoring reusable modules. */ export function define(definition: definition): definition { return definition; } + /** Build a function whose arguments and optional declared return are validated. */ + export const fn: FnParser = makeFn(); + + /** Fix an externally declared static type while retaining runtime validation. */ + // biome-ignore lint/complexity/noBannedTypes: empty default options object + export const declare = (): DeclaredParser => + ({ + type: definition => type(definition) as unknown as FluentType>, + }) as DeclaredParser; + /** Build a lazy named scope from aliases and recursive definitions. */ export function scope(aliases: Record, options?: ScopeOptions): TypeScope { return buildScope(aliases, options); @@ -1456,30 +3606,32 @@ export namespace type { /** Compile a named schema module whose definitions may reference each other. */ export function module>( definitions: definitions, + options?: ScopeOptions, ): { [name in keyof definitions]: Type, InferDefIn> } { - return scope(definitions).export() as unknown as { + return scope(definitions, options).export() as unknown as { [name in keyof definitions]: Type, InferDefIn>; }; } - /** Build a runtime generic whose parameter names are supplied as `""`. */ - export function generic( - parameters: string, - definition: definition, - ): (...arguments_: readonly unknown[]) => BaseType { - const names = parameters - .replace(/^<|>$/g, "") - .split(",") - .map(name => name.trim()) - .filter(Boolean); - return (...arguments_) => { - if (arguments_.length !== names.length) { - throw new OmpTypeError(`generic expects ${names.length} arguments (received ${arguments_.length})`); + type GenericParameterSpec = string | readonly [name: string, constraint: unknown]; + + /** Build a generic directly from an angle-bracket declaration. */ + export function generic(parameters: `<${string}>`, definition: definition): Generic; + /** Build a curried generic from named, optionally constrained parameters. */ + export function generic(...parameters: readonly GenericParameterSpec[]): GenericBuilder; + export function generic(...arguments_: readonly (GenericParameterSpec | unknown)[]): Generic | GenericBuilder { + if (arguments_.length === 2 && typeof arguments_[0] === "string" && arguments_[0].trimStart().startsWith("<")) { + return createRuntimeGeneric(parseGenericParameters(arguments_[0]), arguments_[1]); + } + const parameters: GenericParameter[] = arguments_.map(parameter => { + if (typeof parameter === "string") return { name: parameter.trim() }; + if (Array.isArray(parameter) && typeof parameter[0] === "string") { + return { name: parameter[0].trim(), constraintDef: parameter[1] }; } - const aliases: Record = {}; - for (let index = 0; index < names.length; index++) aliases[names[index]] = arguments_[index]; - return scope(aliases).type(definition) as unknown as BaseType; - }; + throw new OmpTypeError("generic parameters must be names or [name, constraint] pairs"); + }); + validateGenericParameters(parameters); + return (definition: unknown) => createRuntimeGeneric(parameters, definition); } /** Untyped builder for runtime-assembled definitions. */ @@ -1499,17 +3651,31 @@ Object.assign(type, { export interface ScopeOptions { jitless?: boolean; + clone?: false | ((input: unknown) => unknown); + divisor?: SchemaConfig; } /** Callable builder bound to one alias scope. */ -export type ScopedBuilder = ( +export type ScopedBuilder = (( definition: definition, -) => FluentType, InferDefIn>; +) => FluentType, InferDefIn>) & + typeof type; -/** Named schema scope with a scoped builder and compiled module export. */ +const MODULE_SCOPE = Symbol("omptype.moduleScope"); + +interface RuntimeModule extends Record { + readonly [MODULE_SCOPE]: TypeScope; +} + +/** Named schema scope with scoped parsing, imports, and bound module exports. */ export interface TypeScope { readonly type: ScopedBuilder; - export(): Record; + readonly match: MatchParser; + readonly json: Record; + define(definition: definition): definition; + resolve(name: string): BaseType; + import(...names: readonly string[]): Record; + export(...names: readonly string[]): Record; } /** Build a scope whose aliases resolve lazily, including recursive cycles. */ @@ -1517,11 +3683,92 @@ export function scope(aliases: Record, options?: ScopeOptions): return buildScope(aliases, options); } -function buildScope(aliases: Record, _options?: ScopeOptions): TypeScope { +export namespace scope { + /** Preserve a scope definition's literal shape without constructing it. */ + export function define(definitions: aliases): aliases { + return definitions; + } +} + +interface ScopeAlias { + readonly name: string; + readonly sourceName: string; + readonly private: boolean; + readonly genericParameters?: readonly GenericParameter[]; + definition: unknown; + generic?: RuntimeGeneric; + materialized: boolean; +} + +function isRuntimeModule(value: unknown): value is RuntimeModule { + return typeof value === "object" && value !== null && MODULE_SCOPE in value; +} + +function buildScope(aliases: Record, options?: ScopeOptions): TypeScope { + const scopeMeta: TypeMeta = options?.clone === undefined ? EMPTY_META : { clone: options.clone }; + const withScopeConfig = (ir: IR): IR => + options?.divisor === undefined ? ir : configureSelected(ir, options.divisor, { kind: "divisor" }); + const entries = new Map(); + for (const sourceName in aliases) { + const isPrivate = sourceName.startsWith("#"); + const visibleName = isPrivate ? sourceName.slice(1) : sourceName; + const declaration = parseGenericDeclaration(visibleName); + const external = isRuntimeGeneric(aliases[sourceName]) ? aliases[sourceName] : undefined; + const name = declaration?.name ?? visibleName; + if (entries.has(name)) throw new OmpTypeError(`alias "${name}" is declared as both public and private`); + entries.set(name, { + name, + sourceName, + private: isPrivate, + genericParameters: declaration?.parameters ?? external?.[GENERIC_META].parameters, + definition: aliases[sourceName], + generic: external, + materialized: false, + }); + } + const references = new Map(); const targets = new Map(); - const resolve: AliasResolver = name => { - if (!Object.hasOwn(aliases, name)) return undefined; + let scopeValue: TypeScope; + + const materialize = (entry: ScopeAlias): unknown => { + if (entry.materialized) return entry.definition; + entry.materialized = true; + if ( + entry.genericParameters === undefined && + typeof entry.definition === "function" && + !(IR_BRAND in entry.definition) + ) { + entry.definition = Reflect.apply(entry.definition, undefined, []); + } + return entry.definition; + }; + + const moduleSchema = (module: RuntimeModule, parts: readonly string[]): EmbeddableSchema | undefined => { + let current: BaseType | RuntimeModule = module; + for (const part of parts) { + if (!isRuntimeModule(current)) return undefined; + const next: BaseType | RuntimeModule | undefined = current[part]; + if (next === undefined) return undefined; + current = next; + } + if (isRuntimeModule(current)) { + const root = current.root; + return root !== undefined && !isRuntimeModule(root) ? root : undefined; + } + return current; + }; + + const resolve = ((path: string): IR | undefined => { + const [name, ...parts] = path.split("."); + const entry = entries.get(name); + if (entry === undefined || entry.genericParameters !== undefined) return undefined; + const definition = materialize(entry); + if (isRuntimeModule(definition)) { + const schema = moduleSchema(definition, parts); + return schema === undefined ? undefined : embed(schema); + } + if (parts.length !== 0) return undefined; const existing = references.get(name); if (existing !== undefined) return existing; const reference: IR = { @@ -1530,30 +3777,197 @@ function buildScope(aliases: Record, _options?: ScopeOptions): resolve: () => { const target = targets.get(name); if (target !== undefined) return target; - const parsed = parseDef(aliases[name], resolve); + const parsed = parseDef(definition, resolve); targets.set(name, parsed); return parsed; }, }; references.set(name, reference); return reference; + }) as AliasResolver; + + const genericFor = (entry: ScopeAlias): RuntimeGeneric => { + if (entry.generic !== undefined) return entry.generic; + const parameters = entry.genericParameters; + if (parameters === undefined) throw new OmpTypeError(`alias "${entry.name}" is not generic`); + entry.generic = createRuntimeGeneric(parameters, entry.definition, resolve, false); + return entry.generic; }; - const scoped = Object.assign( - (definition: unknown) => makeType(parseDef(definition, resolve), [], {}), - type, - ) as unknown as ScopedBuilder; - return { + const genericInstantiations = new Map(); + resolve.hasGeneric = name => entries.get(name)?.genericParameters !== undefined; + resolve.generic = (name, arguments_) => { + const entry = entries.get(name); + if (entry === undefined || entry.genericParameters === undefined) return undefined; + const key = `${name}<${arguments_.map(expectedOf).join(",")}>`; + const existing = genericInstantiations.get(key); + if (existing !== undefined) return existing; + let target: IR | undefined; + const reference: IR = { + k: "alias", + name: key, + resolve: () => { + target ??= genericFor(entry)[GENERIC_META].instantiateIR(arguments_); + return target; + }, + }; + genericInstantiations.set(key, reference); + target = genericFor(entry)[GENERIC_META].instantiateIR(arguments_); + return target; + }; + + const bind = (schema: InternalType): InternalType => { + Reflect.set(schema, "$", scopeValue); + Reflect.set(schema, "resolver", resolve); + return schema; + }; + const parseScoped = (definition: unknown): InternalType => + bind(makeType(withScopeConfig(parseDef(definition, resolve)), EMPTY_STEPS, scopeMeta)); + const scopedMatch = createMatchParser({ + parse: definition => parseScoped(definition) as unknown as BaseType, + branches: [], + }); + const scoped = Object.assign((definition: unknown) => parseScoped(definition), type, { + fn: makeFn(resolve), + match: scopedMatch, + ...naryStatics(resolve), + }) as unknown as ScopedBuilder; + + const targetFor = (name: string): IR => { + const resolved = resolve(name); + if (resolved === undefined) throw new OmpTypeError(`unknown alias "${name}"`); + return resolved.k === "alias" ? resolved.resolve() : resolved; + }; + const schemaFor = (name: string): BaseType => + bind(makeType(withScopeConfig(targetFor(name)), EMPTY_STEPS, scopeMeta)) as unknown as BaseType; + + const bindModule = (names: readonly string[]): RuntimeModule => { + const module = {} as RuntimeModule; + Object.defineProperty(module, MODULE_SCOPE, { value: scopeValue }); + for (const name of names) { + const entry = entries.get(name); + if (entry === undefined) continue; + if (entry.genericParameters !== undefined) { + module[name] = genericFor(entry) as unknown as BaseType; + continue; + } + const definition = materialize(entry); + module[name] = isRuntimeModule(definition) ? definition : schemaFor(name); + } + return module; + }; + + scopeValue = { type: scoped, - export() { - const schemas: Record = {}; - for (const name in aliases) schemas[name] = scoped(name) as unknown as BaseType; - return schemas; + match: scopedMatch, + define(definition: definition): definition { + return definition; + }, + resolve(name: string) { + return schemaFor(name); + }, + import(...names: readonly string[]) { + const selected = + names.length === 0 + ? [...entries.values()].filter(entry => !entry.private) + : names.map(name => { + const entry = entries.get(name); + if (entry === undefined) throw new OmpTypeError(`unknown alias "${name}"`); + return entry; + }); + const imported: Record = {}; + for (const entry of selected) { + imported[`#${entry.sourceName.startsWith("#") ? entry.sourceName.slice(1) : entry.sourceName}`] = + entry.genericParameters === undefined ? schemaFor(entry.name) : genericFor(entry); + } + return imported; + }, + export(...names: readonly string[]) { + const selected = + names.length === 0 ? [...entries.values()].filter(entry => !entry.private).map(entry => entry.name) : names; + // Export is the eager boundary: malformed aliases and bad thunks fail here, + // while recursive references inside valid definitions stay lazy. + for (const entry of entries.values()) { + if (entry.genericParameters !== undefined) genericFor(entry); + else if (!isRuntimeModule(materialize(entry))) targetFor(entry.name); + } + return bindModule(selected) as unknown as Record; + }, + get json() { + const json: Record = {}; + const add = (prefix: string, module: RuntimeModule): void => { + for (const name of Object.keys(module)) { + const value = module[name]; + const path = prefix === "" ? name : `${prefix}.${name}`; + if (isRuntimeModule(value)) add(path, value); + else json[path] = Reflect.get(value, "json"); + } + }; + add("", bindModule([...entries.values()].filter(entry => !entry.private).map(entry => entry.name))); + return json; }, }; + return scopeValue; } /** A schema whose output type is not statically known (`type.raw` results). */ export type BaseType = FluentType; +/** + * Minimal structural constraint matching any omptype schema. + * + * `FluentType`'s recursive fluent surface makes `T extends FluentType<...>` + * checks descend until TypeScript's depth limiter reports spurious + * incompatibilities, and its invariant input parameter rejects concrete + * schemas outright. This interface exposes only the schema marker plus the + * members generic helpers commonly need — method syntax keeps parameter + * positions bivariant, and returns recurse shallowly through `AnyType`. + */ +export interface AnyType { + (data: unknown): unknown; + readonly [IR_BRAND]: true; + readonly ir: IR; + readonly infer: unknown; + readonly inferIn: unknown; + readonly hasDefault: boolean; + readonly description?: string; + run(data: unknown): unknown; + assert(data: unknown): unknown; + allows(data: unknown): boolean; + toJsonSchema(options?: ToJsonSchemaOptions): Record; + describe(description: string): AnyType; + default(value: unknown): AnyType; + or(def: Def): AnyType; + and(def: Def): AnyType; + pipe(fn: (data: never, ctx: NarrowContext) => unknown): AnyType; + narrow(fn: (data: never, ctx: NarrowContext) => unknown): AnyType; + array(): AnyType; +} +declare const submoduleType: unique symbol; + +type BoundAlias = value extends Submodule ? Submodule : FluentType; + +/** Exported aliases from a scope, each bound to that scope's resolver. */ +export type Module> = { + readonly [name in keyof aliases]: BoundAlias; +}; + +/** A module nested under an alias rather than directly parseable as a schema. */ +export type Submodule> = { + readonly [submoduleType]?: aliases; +} & { + readonly [name in keyof aliases]: BoundAlias; +}; + +/** A selected module export whose schemas retain access to the full scope. */ +export type BoundModule< + exports extends Record, + _allAliases extends Record = exports, +> = Module; + +/** Type-level view of a named scope. */ +export type Scope> = TypeScope & { + readonly t: aliases; +}; + /** `hasMorph` re-export for diagnostics/tooling. */ export { hasMorph }; diff --git a/packages/omptype/src/typebox.ts b/packages/omptype/src/typebox.ts index 646e183bf..c3243050a 100644 --- a/packages/omptype/src/typebox.ts +++ b/packages/omptype/src/typebox.ts @@ -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 { safeParse(input: unknown): TypeBoxSafeParseResult; } -export type TSchema = OmpType & LegacyTypeBoxCompat; -export type Static = T["infer"]; -export type TAny = TSchema; -export type TUnknown = TSchema; -export type TNever = TSchema; -export type TNull = TSchema; -export type TString = TSchema; -export type TNumber = TSchema; -export type TInteger = TSchema; -export type TBoolean = TSchema; -export type TLiteral = TSchema; -export type TArray = TSchema[]>; -export type TTuple = 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`. 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; +} + +/** + * 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; +} + +/** Schema carrying a statically known type; every `TXxx` alias resolves here. */ +export type TTyped = OmpType & LegacyTypeBoxCompat; +export type Static = T["infer"]; +export type TAny = TTyped; +export type TUnknown = TTyped; +export type TNever = TTyped; +export type TNull = TTyped; +export type TString = TTyped; +export type TNumber = TTyped; +export type TInteger = TTyped; +export type TBoolean = TTyped; +export type TLiteral = TTyped; +export type TArray = TTyped[]>; +export type TTuple = TTyped<{ -readonly [K in keyof E]: Static; }>; -export type TOptional = TSchema | undefined> & { readonly [OPTIONAL_INNER]: E }; -export type TUnion = TSchema>; -export type TIntersect = TSchema< +export type TOptional = TTyped | undefined> & { readonly [OPTIONAL_INNER]: E }; +export type TUnion = TTyped>; +export type TIntersect = TTyped< UnionToIntersection> >; -export type TEnum = TSchema; -export type TRecord = TSchema, PropertyKey>, Static>>; -export type TNullable = TSchema | null>; -export type TReadonly = TSchema>>; -export type TUnsafe = TSchema; +export type TEnum = TTyped; +export type TRecord = TTyped< + Record, PropertyKey>, Static> +>; +export type TNullable = TTyped | null>; +export type TReadonly = TTyped>>; +export type TUnsafe = TTyped; -type OptionalKeys

> = { - [K in keyof P]-?: P[K] extends { readonly [OPTIONAL_INNER]: TSchema } ? K : never; +type OptionalKeys

> = { + [K in keyof P]-?: P[K] extends { readonly [OPTIONAL_INNER]: AnySchema } ? K : never; }[keyof P]; -type RequiredKeys

> = Exclude>; -type ObjectStatic

> = { +type RequiredKeys

> = Exclude>; +type ObjectStatic

> = { [K in RequiredKeys

]: Static; } & { [K in OptionalKeys

]?: Exclude, undefined>; }; -export type TObject

= Record> = TSchema>; -type RequiredProps

> = { +export type TObject

= Record> = TTyped>; +type RequiredProps

> = { [K in keyof P]: P[K] extends TOptional ? E : P[K]; }; interface RuntimeType extends OmpType { - [OPTIONAL_INNER]?: TSchema; + [OPTIONAL_INNER]?: AnySchema; [OBJECT_INFO]?: ObjectInfo; describe(description: string): RuntimeType; default(value: T | (() => T)): RuntimeType; - or(schema: OmpType): RuntimeType; - and(schema: OmpType): RuntimeType; + or(schema: schema): RuntimeType>; + and(schema: schema): RuntimeType>; array(): RuntimeType; atLeastLength(bound: number): RuntimeType; atMostLength(bound: number): RuntimeType; @@ -109,7 +140,18 @@ interface RuntimeType extends OmpType { } type CompatRuntime = RuntimeType & LegacyTypeBoxCompat; -type ObjectInfo = { props: Record; additionalProperties?: boolean | TSchema }; +type ObjectInfo = { + props: Record; + additionalProperties?: boolean | AnySchema; +}; + +function asRuntime(schema: AnySchema): RuntimeType { + return schema as unknown as RuntimeType; +} + +function asSchema(schema: AnySchema): TTyped { + return schema as unknown as TTyped; +} 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; + let schema = asRuntime(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; + let schema = asRuntime(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(value: V, opts?: Meta): TLiteral { - return applyMeta(type.enumerated(value) as RuntimeType, opts); + return applyMeta(asRuntime(type.enumerated(value)), opts); } function tNever(opts?: Meta): TNever { return applyMeta( - (type.raw("unknown") as RuntimeType).narrow((_value, ctx): _value is never => ctx.mustBe("never")), + asRuntime(type.raw("unknown")).narrow((_value, ctx): _value is never => ctx.mustBe("never")), opts, ); } -function tUnion(schemas: E, opts?: Meta): TUnion { - if (schemas.length === 0) return tNever(opts) as TUnion; - let result = schemas[0] as unknown as RuntimeType; +function tUnion(schemas: E, opts?: Meta): TUnion { + if (schemas.length === 0) return asSchema>(tNever(opts)); + let result = asRuntime(schemas[0]); for (let i = 1; i < schemas.length; i++) result = result.or(schemas[i]); - return applyMeta(result, opts) as TUnion; + return asSchema>(applyMeta(result, opts)); } -function tIntersect( +function tIntersect( schemas: E, opts?: Meta, -): TSchema>> { - if (schemas.length === 0) - return applyMeta(type.raw("unknown") as RuntimeType, opts) as TSchema< - UnionToIntersection> - >; +): TTyped>> { + if (schemas.length === 0) { + return applyMeta(asRuntime>>(type.raw("unknown")), opts); + } const validateAll = (): RuntimeType>> => { - const base = type.raw("unknown") as RuntimeType; + const base = asRuntime(type.raw("unknown")); return base.narrow((value, ctx): value is UnionToIntersection> => { 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( }); }; if (schemas.some(schema => schema.hasSteps)) return applyMeta(validateAll(), opts); - let result = schemas[0] as unknown as RuntimeType; + let result = asRuntime(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>>; + return asSchema>>(applyMeta(result, opts)); } type UnionToIntersection = (U extends unknown ? (value: U) => void : never) extends (value: infer I) => void ? I @@ -306,16 +345,17 @@ function enumValues(values: Record | readonly (string | function tEnum | readonly (string | number)[]>( values: E, opts?: Meta, -): TSchema { - return applyMeta(type.enumerated(...enumValues(values)) as RuntimeType, opts) as unknown as TSchema< - E extends readonly (infer V)[] ? V : E[keyof E] - >; +): TTyped { + return applyMeta( + asRuntime(type.enumerated(...enumValues(values))), + opts, + ); } -function tArray(item: E, opts?: ArrayOpts): TArray { +function tArray(item: E, opts?: ArrayOpts): TArray { checkFiniteOption("minItems", opts?.minItems); checkFiniteOption("maxItems", opts?.maxItems); - let schema = (item as unknown as RuntimeType>).array(); + let schema = asRuntime>(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(items: E, opts?: Meta): TTuple { - const schema = (type.raw("unknown") as RuntimeType).narrow( +function tTuple(items: E, opts?: Meta): TTuple { + const schema = asRuntime(type.raw("unknown")).narrow( (value, ctx): value is { -readonly [K in keyof E]: Static } => { if (!Array.isArray(value) || value.length !== items.length) return ctx.mustBe(`a tuple of length ${items.length}`); @@ -355,25 +395,25 @@ function tTuple(items: E, opts?: Meta): TTup return applyMeta(schema, opts); } -function tObject>(properties: P, opts?: ObjectOpts): TObject

{ +function tObject>(properties: P, opts?: ObjectOpts): TObject

{ const def: Record = {}; - const props: Record = {}; + const props: Record = {}; for (const key in properties) { const schema = properties[key]; - const inner = (schema as unknown as RuntimeType)[OPTIONAL_INNER]; - def[inner ? `${key}?` : key] = (inner ?? schema) as Def; + const inner = asRuntime(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>, opts); + def["[string]"] = opts.additionalProperties; + const schema = applyMeta(asRuntime>(type.raw(def)), opts); schema[OBJECT_INFO] = { props, additionalProperties: opts?.additionalProperties }; return schema; } -function tRecord(key: K, value: V, opts?: Meta): TRecord { - const base = (type.raw({ "[string]": value }) as RuntimeType>>).narrow((record, ctx) => { +function tRecord(key: K, value: V, opts?: Meta): TRecord { + const base = asRuntime>>(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(key: K, value: V, opts?: return applyMeta(base, opts) as TRecord; } -function tOptional(schema: E, opts?: Meta): TOptional { +function tOptional(schema: E, opts?: Meta): TOptional { const marker = applyMeta( - (schema as unknown as RuntimeType>).or(type.raw("undefined")), + asRuntime>(schema).or(asRuntime(type.raw("undefined"))), opts, ) as RuntimeType | undefined>; marker[OPTIONAL_INNER] = schema; return marker as unknown as TOptional; } -function tNullable(schema: E, opts?: Meta): TSchema | null> { - return applyMeta((schema as unknown as RuntimeType>).or(type.raw("null")), opts); +function tNullable(schema: E, opts?: Meta): TTyped | null> { + return applyMeta(asRuntime>(schema).or(asRuntime(type.raw("null"))), opts); } -function requireObject(schema: TSchema, operation: string): ObjectInfo { - const info = (schema as unknown as RuntimeType)[OBJECT_INFO]; +function requireObject(schema: AnySchema, operation: string): ObjectInfo { + const info = asRuntime(schema)[OBJECT_INFO]; if (!info) throw new OmpTypeError(`Type.${operation} requires a schema created by Type.Object`); return info; } -function tPartial

>(schema: TObject

): TSchema>> { +function tPartial

>(schema: TObject

): TTyped>> { const info = requireObject(schema, "Partial"); - const props: Record = {}; + const props: Record = {}; for (const key in info.props) - props[key] = (info.props[key] as unknown as RuntimeType)[OPTIONAL_INNER] - ? info.props[key] - : tOptional(info.props[key]); - return tObject(props, { additionalProperties: info.additionalProperties }) as TSchema>>; + props[key] = asRuntime(info.props[key])[OPTIONAL_INNER] ? info.props[key] : tOptional(info.props[key]); + return tObject(props, { additionalProperties: info.additionalProperties }) as TTyped>>; } -function tRequired

>(schema: TObject

): TObject> { +function tRequired

>(schema: TObject

): TObject> { const info = requireObject(schema, "Required"); - const props: Record = {}; + const props: Record = {}; for (const key in info.props) { - props[key] = (info.props[key] as unknown as RuntimeType)[OPTIONAL_INNER] ?? info.props[key]; + props[key] = asRuntime(info.props[key])[OPTIONAL_INNER] ?? info.props[key]; } return tObject(props, { additionalProperties: info.additionalProperties }) as TObject>; } -function tPick

, const K extends readonly (keyof P)[]>( +function tPick

, const K extends readonly (keyof P)[]>( schema: TObject

, keys: K, ): TObject> { const info = requireObject(schema, "Pick"); - const props: Record = {}; + const props: Record = {}; 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>; } -function tOmit

, const K extends readonly (keyof P)[]>( +function tOmit

, const K extends readonly (keyof P)[]>( schema: TObject

, keys: K, ): TObject> { const info = requireObject(schema, "Omit"); const omitted = new Set(keys); - const props: Record = {}; + const props: Record = {}; for (const key in info.props) if (!omitted.has(key)) props[key] = info.props[key]; return tObject(props, { additionalProperties: info.additionalProperties }) as TObject>; } -function tComposite>[]>( +function tComposite>[]>( schemas: E, opts?: ObjectOpts, -): TSchema>> { - const props: Record = {}; +): TTyped>> { + const props: Record = {}; for (const schema of schemas) Object.assign(props, requireObject(schema, "Composite").props); - return tObject(props, opts) as TSchema>>; + return asSchema>>(tObject(props, opts)); } function tUnsafe(_jsonSchema: Record = {}): TUnsafe { @@ -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, opts), - Null: (opts?: Meta) => applyMeta(type.raw("null") as RuntimeType, opts), - Any: (opts?: Meta) => applyMeta(type.raw("unknown") as RuntimeType, opts), - Unknown: (opts?: Meta) => applyMeta(type.raw("unknown") as RuntimeType, opts), + Boolean: (opts?: Meta) => applyMeta(asRuntime(type.raw("boolean")), opts), + Null: (opts?: Meta) => applyMeta(asRuntime(type.raw("null")), opts), + Any: (opts?: Meta) => applyMeta(asRuntime(type.raw("unknown")), opts), + Unknown: (opts?: Meta) => applyMeta(asRuntime(type.raw("unknown")), opts), Never: tNever, Literal: tLiteral, Union: tUnion, @@ -474,7 +512,8 @@ export const Type = { Record: tRecord, Optional: tOptional, Nullable: tNullable, - Readonly: (schema: E): E => withLegacyCompat(schema) as unknown as E, + Readonly: (schema: E): TReadonly => + asSchema>>(withLegacyCompat(asRuntime>>(schema))), Partial: tPartial, Required: tRequired, Pick: tPick, diff --git a/packages/omptype/src/zod.ts b/packages/omptype/src/zod.ts index e5ad78d37..02aff3209 100644 --- a/packages/omptype/src/zod.ts +++ b/packages/omptype/src/zod.ts @@ -71,7 +71,7 @@ function restrictBase(source: Decoratable, ir: IR): Decoratable { let next = source.hasSteps ? schemaFromIR({ k: "morph", input: ir, fn: value => source(value) }) : schemaFromIR(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; } diff --git a/packages/omptype/test/type.test.ts b/packages/omptype/test/type.test.ts index bbc2fba55..5412d5c5e 100644 --- a/packages/omptype/test/type.test.ts +++ b/packages/omptype/test/type.test.ts @@ -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); + }); });