import { append, conflatenate, flatMorph, printable, spliterate, throwInternalError, throwParseError } from "@ark/util"; import { BaseConstraint, constraintKeyParser, flattenConstraints, intersectConstraints } from "../constraint.js"; import { intrinsic } from "../intrinsic.js"; import { typeOrTermExtends } from "../roots/root.js"; import { compileSerializedValue } from "../shared/compile.js"; import { Disjoint } from "../shared/disjoint.js"; import { implementNode } from "../shared/implement.js"; import { intersectNodesRoot } from "../shared/intersections.js"; import { $ark, registeredReference } from "../shared/registry.js"; import { ToJsonSchema } from "../shared/toJsonSchema.js"; import { traverseKey } from "../shared/traversal.js"; import { hasArkKind, isNode, makeRootAndArrayPropertiesMutable } from "../shared/utils.js"; import { Optional } from "./optional.js"; const createStructuralWriter = (childStringProp) => (node) => { if (node.props.length || node.index) { const parts = node.index?.map(index => index[childStringProp]) ?? []; for (const prop of node.props) parts.push(prop[childStringProp]); if (node.undeclared) parts.push(`+ (undeclared): ${node.undeclared}`); const objectLiteralDescription = `{ ${parts.join(", ")} }`; return node.sequence ? `${objectLiteralDescription} & ${node.sequence.description}` : objectLiteralDescription; } return node.sequence?.description ?? "{}"; }; const structuralDescription = createStructuralWriter("description"); const structuralExpression = createStructuralWriter("expression"); const intersectPropsAndIndex = (l, r, $) => { const kind = l.required ? "required" : "optional"; if (!r.signature.allows(l.key)) return null; const value = intersectNodesRoot(l.value, r.value, $); if (value instanceof Disjoint) { return kind === "optional" ? $.node("optional", { key: l.key, value: $ark.intrinsic.never.internal }) : value.withPrefixKey(l.key, l.kind); } return null; }; const implementation = implementNode({ kind: "structure", hasAssociatedError: false, normalize: schema => schema, applyConfig: (schema, config) => { if (!schema.undeclared && config.onUndeclaredKey !== "ignore") { return { ...schema, undeclared: config.onUndeclaredKey }; } return schema; }, keys: { required: { child: true, parse: constraintKeyParser("required"), reduceIo: (ioKind, inner, nodes) => { // ensure we don't overwrite nodes added by optional inner.required = append(inner.required, nodes.map(node => (ioKind === "in" ? node.rawIn : node.rawOut))); return; } }, optional: { child: true, parse: constraintKeyParser("optional"), reduceIo: (ioKind, inner, nodes) => { if (ioKind === "in") { inner.optional = nodes.map(node => node.rawIn); return; } for (const node of nodes) { inner[node.outProp.kind] = append(inner[node.outProp.kind], node.outProp.rawOut); } } }, index: { child: true, parse: constraintKeyParser("index") }, sequence: { child: true, parse: constraintKeyParser("sequence") }, undeclared: { parse: behavior => (behavior === "ignore" ? undefined : behavior), reduceIo: (ioKind, inner, value) => { if (value === "reject") { inner.undeclared = "reject"; return; } // if base is "delete", undeclared keys are "ignore" (i.e. unconstrained) // on input and "reject" on output if (ioKind === "in") delete inner.undeclared; else inner.undeclared = "reject"; } } }, defaults: { description: structuralDescription }, intersections: { structure: (l, r, ctx) => { const lInner = { ...l.inner }; const rInner = { ...r.inner }; const disjointResult = new Disjoint(); if (l.undeclared) { const lKey = l.keyof(); for (const k of r.requiredKeys) { if (!lKey.allows(k)) { disjointResult.add("presence", $ark.intrinsic.never.internal, r.propsByKey[k].value, { path: [k] }); } } if (rInner.optional) rInner.optional = rInner.optional.filter(n => lKey.allows(n.key)); if (rInner.index) { rInner.index = rInner.index.flatMap(n => { if (n.signature.extends(lKey)) return n; const indexOverlap = intersectNodesRoot(lKey, n.signature, ctx.$); if (indexOverlap instanceof Disjoint) return []; const normalized = normalizeIndex(indexOverlap, n.value, ctx.$); if (normalized.required) { rInner.required = conflatenate(rInner.required, normalized.required); } if (normalized.optional) { rInner.optional = conflatenate(rInner.optional, normalized.optional); } return normalized.index ?? []; }); } } if (r.undeclared) { const rKey = r.keyof(); for (const k of l.requiredKeys) { if (!rKey.allows(k)) { disjointResult.add("presence", l.propsByKey[k].value, $ark.intrinsic.never.internal, { path: [k] }); } } if (lInner.optional) lInner.optional = lInner.optional.filter(n => rKey.allows(n.key)); if (lInner.index) { lInner.index = lInner.index.flatMap(n => { if (n.signature.extends(rKey)) return n; const indexOverlap = intersectNodesRoot(rKey, n.signature, ctx.$); if (indexOverlap instanceof Disjoint) return []; const normalized = normalizeIndex(indexOverlap, n.value, ctx.$); if (normalized.required) { lInner.required = conflatenate(lInner.required, normalized.required); } if (normalized.optional) { lInner.optional = conflatenate(lInner.optional, normalized.optional); } return normalized.index ?? []; }); } } const baseInner = {}; if (l.undeclared || r.undeclared) { baseInner.undeclared = l.undeclared === "reject" || r.undeclared === "reject" ? "reject" : "delete"; } const childIntersectionResult = intersectConstraints({ kind: "structure", baseInner, l: flattenConstraints(lInner), r: flattenConstraints(rInner), roots: [], ctx }); if (childIntersectionResult instanceof Disjoint) disjointResult.push(...childIntersectionResult); if (disjointResult.length) return disjointResult; return childIntersectionResult; } }, reduce: (inner, $) => { if (!inner.required && !inner.optional) return; const seen = {}; let updated = false; const newOptionalProps = inner.optional ? [...inner.optional] : []; // check required keys for duplicates and handle index intersections if (inner.required) { for (let i = 0; i < inner.required.length; i++) { const requiredProp = inner.required[i]; if (requiredProp.key in seen) throwParseError(writeDuplicateKeyMessage(requiredProp.key)); seen[requiredProp.key] = true; if (inner.index) { for (const index of inner.index) { const intersection = intersectPropsAndIndex(requiredProp, index, $); if (intersection instanceof Disjoint) return intersection; } } } } // check optional keys for duplicates and handle index intersections if (inner.optional) { for (let i = 0; i < inner.optional.length; i++) { const optionalProp = inner.optional[i]; if (optionalProp.key in seen) throwParseError(writeDuplicateKeyMessage(optionalProp.key)); seen[optionalProp.key] = true; if (inner.index) { for (const index of inner.index) { const intersection = intersectPropsAndIndex(optionalProp, index, $); if (intersection instanceof Disjoint) return intersection; if (intersection !== null) { newOptionalProps[i] = intersection; updated = true; } } } } } if (updated) { return $.node("structure", { ...inner, optional: newOptionalProps }, { prereduced: true }); } } }); export class StructureNode extends BaseConstraint { impliedBasis = $ark.intrinsic.object.internal; impliedSiblings = this.children.flatMap(n => n.impliedSiblings ?? []); props = conflatenate(this.required, this.optional); propsByKey = flatMorph(this.props, (i, node) => [node.key, node]); propsByKeyReference = registeredReference(this.propsByKey); expression = structuralExpression(this); requiredKeys = this.required?.map(node => node.key) ?? []; optionalKeys = this.optional?.map(node => node.key) ?? []; literalKeys = [...this.requiredKeys, ...this.optionalKeys]; _keyof; keyof() { if (this._keyof) return this._keyof; let branches = this.$.units(this.literalKeys).branches; if (this.index) { for (const { signature } of this.index) branches = branches.concat(signature.branches); } return (this._keyof = this.$.node("union", branches)); } map(flatMapProp) { return this.$.node("structure", this.props .flatMap(flatMapProp) .reduce((structureInner, mapped) => { const originalProp = this.propsByKey[mapped.key]; if (isNode(mapped)) { if (mapped.kind !== "required" && mapped.kind !== "optional") { return throwParseError(`Map result must have kind "required" or "optional" (was ${mapped.kind})`); } structureInner[mapped.kind] = append(structureInner[mapped.kind], mapped); return structureInner; } const mappedKind = mapped.kind ?? originalProp?.kind ?? "required"; // extract the inner keys from the map result in case a node was spread, // which would otherwise lead to invalid keys const mappedPropInner = flatMorph(mapped, (k, v) => (k in Optional.implementation.keys ? [k, v] : [])); structureInner[mappedKind] = append(structureInner[mappedKind], this.$.node(mappedKind, mappedPropInner)); return structureInner; }, {})); } assertHasKeys(keys) { const invalidKeys = keys.filter(k => !typeOrTermExtends(k, this.keyof())); if (invalidKeys.length) { return throwParseError(writeInvalidKeysMessage(this.expression, invalidKeys)); } } get(indexer, ...path) { let value; let required = false; const key = indexerToKey(indexer); if ((typeof key === "string" || typeof key === "symbol") && this.propsByKey[key]) { value = this.propsByKey[key].value; required = this.propsByKey[key].required; } if (this.index) { for (const n of this.index) { if (typeOrTermExtends(key, n.signature)) value = value?.and(n.value) ?? n.value; } } if (this.sequence && typeOrTermExtends(key, $ark.intrinsic.nonNegativeIntegerString)) { if (hasArkKind(key, "root")) { if (this.sequence.variadic) // if there is a variadic element and we're accessing an index, return a union // of all possible elements. If there is no variadic expression, we're in a tuple // so this access wouldn't be safe based on the array indices value = value?.and(this.sequence.element) ?? this.sequence.element; } else { const index = Number.parseInt(key); if (index < this.sequence.prevariadic.length) { const fixedElement = this.sequence.prevariadic[index].node; value = value?.and(fixedElement) ?? fixedElement; required ||= index < this.sequence.prefixLength; } else if (this.sequence.variadic) { // ideally we could return something more specific for postfix // but there is no way to represent it using an index alone const nonFixedElement = this.$.node("union", this.sequence.variadicOrPostfix); value = value?.and(nonFixedElement) ?? nonFixedElement; } } } if (!value) { if (this.sequence?.variadic && hasArkKind(key, "root") && key.extends($ark.intrinsic.number)) { return throwParseError(writeNumberIndexMessage(key.expression, this.sequence.expression)); } return throwParseError(writeInvalidKeysMessage(this.expression, [key])); } const result = value.get(...path); return required ? result : result.or($ark.intrinsic.undefined); } pick(...keys) { this.assertHasKeys(keys); return this.$.node("structure", this.filterKeys("pick", keys)); } omit(...keys) { this.assertHasKeys(keys); return this.$.node("structure", this.filterKeys("omit", keys)); } optionalize() { const { required, ...inner } = this.inner; return this.$.node("structure", { ...inner, optional: this.props.map(prop => prop.hasKind("required") ? this.$.node("optional", prop.inner) : prop) }); } require() { const { optional, ...inner } = this.inner; return this.$.node("structure", { ...inner, required: this.props.map(prop => prop.hasKind("optional") ? { key: prop.key, value: prop.value } : prop) }); } merge(r) { const inner = this.filterKeys("omit", [r.keyof()]); if (r.required) inner.required = append(inner.required, r.required); if (r.optional) inner.optional = append(inner.optional, r.optional); if (r.index) inner.index = append(inner.index, r.index); if (r.sequence) inner.sequence = r.sequence; if (r.undeclared) inner.undeclared = r.undeclared; else delete inner.undeclared; return this.$.node("structure", inner); } filterKeys(operation, keys) { const result = makeRootAndArrayPropertiesMutable(this.inner); const shouldKeep = (key) => { const matchesKey = keys.some(k => typeOrTermExtends(key, k)); return operation === "pick" ? matchesKey : !matchesKey; }; if (result.required) result.required = result.required.filter(prop => shouldKeep(prop.key)); if (result.optional) result.optional = result.optional.filter(prop => shouldKeep(prop.key)); if (result.index) result.index = result.index.filter(index => shouldKeep(index.signature)); return result; } traverseAllows = (data, ctx) => this._traverse("Allows", data, ctx); traverseApply = (data, ctx) => this._traverse("Apply", data, ctx); _traverse = (traversalKind, data, ctx) => { const errorCount = ctx?.currentErrorCount ?? 0; for (let i = 0; i < this.props.length; i++) { if (traversalKind === "Allows") { if (!this.props[i].traverseAllows(data, ctx)) return false; } else { this.props[i].traverseApply(data, ctx); if (ctx.failFast && ctx.currentErrorCount > errorCount) return false; } } if (this.sequence) { if (traversalKind === "Allows") { if (!this.sequence.traverseAllows(data, ctx)) return false; } else { this.sequence.traverseApply(data, ctx); if (ctx.failFast && ctx.currentErrorCount > errorCount) return false; } } if (this.index || this.undeclared === "reject") { const keys = Object.keys(data); keys.push(...Object.getOwnPropertySymbols(data)); for (let i = 0; i < keys.length; i++) { const k = keys[i]; if (this.index) { for (const node of this.index) { if (node.signature.traverseAllows(k, ctx)) { if (traversalKind === "Allows") { const result = traverseKey(k, () => node.value.traverseAllows(data[k], ctx), ctx); if (!result) return false; } else { traverseKey(k, () => node.value.traverseApply(data[k], ctx), ctx); if (ctx.failFast && ctx.currentErrorCount > errorCount) return false; } } } } if (this.undeclared === "reject" && !this.declaresKey(k)) { if (traversalKind === "Allows") return false; // this should have its own error code: // https://github.com/arktypeio/arktype/issues/1403 ctx.errorFromNodeContext({ code: "predicate", expected: "removed", actual: "", relativePath: [k], meta: this.meta }); if (ctx.failFast) return false; } } } // added additional ctx check here to address // https://github.com/arktypeio/arktype/issues/1346 if (this.structuralMorph && ctx && !ctx.hasError()) ctx.queueMorphs([this.structuralMorph]); return true; }; get defaultable() { return this.cacheGetter("defaultable", this.optional?.filter(o => o.hasDefault()) ?? []); } declaresKey = (k) => k in this.propsByKey || this.index?.some(n => n.signature.allows(k)) || (this.sequence !== undefined && $ark.intrinsic.nonNegativeIntegerString.allows(k)); _compileDeclaresKey(js) { const parts = []; if (this.props.length) parts.push(`k in ${this.propsByKeyReference}`); if (this.index) { for (const index of this.index) parts.push(js.invoke(index.signature, { kind: "Allows", arg: "k" })); } if (this.sequence) parts.push("$ark.intrinsic.nonNegativeIntegerString.allows(k)"); // if parts is empty, this is a structure like { "+": "reject" } // that declares no keys, so return false return parts.join(" || ") || "false"; } get structuralMorph() { return this.cacheGetter("structuralMorph", getPossibleMorph(this)); } structuralMorphRef = this.structuralMorph && registeredReference(this.structuralMorph); compile(js) { if (js.traversalKind === "Apply") js.initializeErrorCount(); for (const prop of this.props) { js.check(prop); if (js.traversalKind === "Apply") js.returnIfFailFast(); } if (this.sequence) { js.check(this.sequence); if (js.traversalKind === "Apply") js.returnIfFailFast(); } if (this.index || this.undeclared === "reject") { js.const("keys", "Object.keys(data)"); js.line("keys.push(...Object.getOwnPropertySymbols(data))"); js.for("i < keys.length", () => this.compileExhaustiveEntry(js)); } if (js.traversalKind === "Allows") return js.return(true); // always queue deleteUndeclared on valid traversal for "delete" if (this.structuralMorphRef) { // added additional ctx check here to address // https://github.com/arktypeio/arktype/issues/1346 js.if("ctx && !ctx.hasError()", () => { js.line(`ctx.queueMorphs([`); precompileMorphs(js, this); return js.line("])"); }); } } compileExhaustiveEntry(js) { js.const("k", "keys[i]"); if (this.index) { for (const node of this.index) { js.if(`${js.invoke(node.signature, { arg: "k", kind: "Allows" })}`, () => js.traverseKey("k", "data[k]", node.value)); } } if (this.undeclared === "reject") { js.if(`!(${this._compileDeclaresKey(js)})`, () => { if (js.traversalKind === "Allows") return js.return(false); return js .line(`ctx.errorFromNodeContext({ code: "predicate", expected: "removed", actual: "", relativePath: [k], meta: ${this.compiledMeta} })`) .if("ctx.failFast", () => js.return()); }); } return js; } reduceJsonSchema(schema, ctx) { switch (schema.type) { case "object": return this.reduceObjectJsonSchema(schema, ctx); case "array": const arraySchema = this.sequence?.reduceJsonSchema(schema, ctx) ?? schema; if (this.props.length || this.index) { return ctx.fallback.arrayObject({ code: "arrayObject", base: arraySchema, object: this.reduceObjectJsonSchema({ type: "object" }, ctx) }); } return arraySchema; default: return ToJsonSchema.throwInternalOperandError("structure", schema); } } reduceObjectJsonSchema(schema, ctx) { if (this.props.length) { schema.properties = {}; for (const prop of this.props) { const valueSchema = prop.value.toJsonSchemaRecurse(ctx); if (typeof prop.key === "symbol") { ctx.fallback.symbolKey({ code: "symbolKey", base: schema, key: prop.key, value: valueSchema, optional: prop.optional }); continue; } if (prop.hasDefault()) { const value = typeof prop.default === "function" ? prop.default() : prop.default; valueSchema.default = $ark.intrinsic.jsonData.allows(value) ? value : ctx.fallback.defaultValue({ code: "defaultValue", base: valueSchema, value }); } schema.properties[prop.key] = valueSchema; } if (this.requiredKeys.length && schema.properties) { schema.required = this.requiredKeys.filter((k) => typeof k === "string" && k in schema.properties); } } if (this.index) { for (const index of this.index) { const valueJsonSchema = index.value.toJsonSchemaRecurse(ctx); if (index.signature.equals($ark.intrinsic.string)) { schema.additionalProperties = valueJsonSchema; continue; } for (const keyBranch of index.signature.branches) { if (!keyBranch.extends($ark.intrinsic.string)) { schema = ctx.fallback.symbolKey({ code: "symbolKey", base: schema, key: null, value: valueJsonSchema, optional: false }); continue; } let keySchema = { type: "string" }; if (keyBranch.hasKind("morph")) { keySchema = ctx.fallback.morph({ code: "morph", base: keyBranch.rawIn.toJsonSchemaRecurse(ctx), out: keyBranch.rawOut.toJsonSchemaRecurse(ctx) }); } if (!keyBranch.hasKind("intersection")) { return throwInternalError(`Unexpected index branch kind ${keyBranch.kind}.`); } const { pattern } = keyBranch.inner; if (pattern) { const keySchemaWithPattern = Object.assign(keySchema, { pattern: pattern[0].rule }); for (let i = 1; i < pattern.length; i++) { keySchema = ctx.fallback.patternIntersection({ code: "patternIntersection", base: keySchemaWithPattern, pattern: pattern[i].rule }); } schema.patternProperties ??= {}; schema.patternProperties[keySchemaWithPattern.pattern] = valueJsonSchema; } } } } if (this.undeclared && !schema.additionalProperties) schema.additionalProperties = false; return schema; } } const defaultableMorphsCache = {}; const constructStructuralMorphCacheKey = (node) => { let cacheKey = ""; for (let i = 0; i < node.defaultable.length; i++) cacheKey += node.defaultable[i].defaultValueMorphRef; if (node.sequence?.defaultValueMorphsReference) cacheKey += node.sequence?.defaultValueMorphsReference; if (node.undeclared === "delete") { cacheKey += "delete !("; if (node.required) for (const n of node.required) cacheKey += n.compiledKey + " | "; if (node.optional) for (const n of node.optional) cacheKey += n.compiledKey + " | "; if (node.index) for (const index of node.index) cacheKey += index.signature.id + " | "; if (node.sequence) { if (node.sequence.maxLength === null) cacheKey += intrinsic.nonNegativeIntegerString.id; else { for (let i = 0; i < node.sequence.tuple.length; i++) cacheKey += i + " | "; } } cacheKey += ")"; } return cacheKey; }; const getPossibleMorph = (node) => { const cacheKey = constructStructuralMorphCacheKey(node); if (!cacheKey) return undefined; if (defaultableMorphsCache[cacheKey]) return defaultableMorphsCache[cacheKey]; const $arkStructuralMorph = (data, ctx) => { for (let i = 0; i < node.defaultable.length; i++) { if (!(node.defaultable[i].key in data)) node.defaultable[i].defaultValueMorph(data, ctx); } if (node.sequence?.defaultables) { for (let i = data.length - node.sequence.prefixLength; i < node.sequence.defaultables.length; i++) node.sequence.defaultValueMorphs[i](data, ctx); } if (node.undeclared === "delete") for (const k in data) if (!node.declaresKey(k)) delete data[k]; return data; }; return (defaultableMorphsCache[cacheKey] = $arkStructuralMorph); }; const precompileMorphs = (js, node) => { const requiresContext = node.defaultable.some(node => node.defaultValueMorph.length === 2) || node.sequence?.defaultValueMorphs.some(morph => morph.length === 2); const args = `(data${requiresContext ? ", ctx" : ""})`; return js.block(`${args} => `, js => { for (let i = 0; i < node.defaultable.length; i++) { const { serializedKey, defaultValueMorphRef } = node.defaultable[i]; js.if(`!(${serializedKey} in data)`, js => js.line(`${defaultValueMorphRef}${args}`)); } if (node.sequence?.defaultables) { js.for(`i < ${node.sequence.defaultables.length}`, js => js.set(`data[i]`, 5), `data.length - ${node.sequence.prefixLength}`); } if (node.undeclared === "delete") { js.forIn("data", js => js.if(`!(${node._compileDeclaresKey(js)})`, js => js.line(`delete data[k]`))); } return js.return("data"); }); }; export const Structure = { implementation, Node: StructureNode }; const indexerToKey = (indexable) => { if (hasArkKind(indexable, "root") && indexable.hasKind("unit")) indexable = indexable.unit; if (typeof indexable === "number") indexable = `${indexable}`; return indexable; }; export const writeNumberIndexMessage = (indexExpression, sequenceExpression) => `${indexExpression} is not allowed as an array index on ${sequenceExpression}. Use the 'nonNegativeIntegerString' keyword instead.`; /** extract enumerable named props from an index signature */ export const normalizeIndex = (signature, value, $) => { const [enumerableBranches, nonEnumerableBranches] = spliterate(signature.branches, k => k.hasKind("unit")); if (!enumerableBranches.length) return { index: $.node("index", { signature, value }) }; const normalized = {}; for (const n of enumerableBranches) { // since required can be reduced to optional if it has a default or // optional meta on its value, we have to assign it depending on the // compiled kind const prop = $.node("required", { key: n.unit, value }); normalized[prop.kind] = append(normalized[prop.kind], prop); } if (nonEnumerableBranches.length) { normalized.index = $.node("index", { signature: nonEnumerableBranches, value }); } return normalized; }; export const typeKeyToString = (k) => hasArkKind(k, "root") ? k.expression : printable(k); export const writeInvalidKeysMessage = (o, keys) => `Key${keys.length === 1 ? "" : "s"} ${keys.map(typeKeyToString).join(", ")} ${keys.length === 1 ? "does" : "do"} not exist on ${o}`; export const writeDuplicateKeyMessage = (key) => `Duplicate key ${compileSerializedValue(key)}`;