diff --git a/ark/docs/public/llms.txt b/ark/docs/public/llms.txt index e76d20bc4c..966d3f8296 100644 --- a/ark/docs/public/llms.txt +++ b/ark/docs/public/llms.txt @@ -1386,7 +1386,7 @@ Add a type-only symbol to an existing type so that the only values that satisfy const Even = type("(number % 2)#even") type Even = typeof Even.infer -const good: Even = even.assert(2) +const good: Even = Even.assert(2) // TypeScript: Type 'number' is not assignable to type 'Brand' const bad: Even = 5 ``` @@ -1397,10 +1397,10 @@ const bad: Even = 5 ```ts // @noErrors -const even = type.number.divisibleBy(2).brand("even") +const Even = type.number.divisibleBy(2).brand("even") type Even = typeof Even.infer -const good: Even = even.assert(2) +const good: Even = Even.assert(2) // TypeScript: Type 'number' is not assignable to type 'Brand' const bad: Even = 5 ``` diff --git a/ark/json-schema/__tests__/array.test.ts b/ark/json-schema/__tests__/array.test.ts index 8d4e7844c9..fcb395172f 100644 --- a/ark/json-schema/__tests__/array.test.ts +++ b/ark/json-schema/__tests__/array.test.ts @@ -8,6 +8,7 @@ import { contextualize(() => { it("type array", () => { const t = jsonSchemaToType({ type: "array" }) + attest(t.infer) attest(t.expression).snap("Array") }) @@ -16,12 +17,14 @@ contextualize(() => { type: "array", items: { type: "string" } }) + attest(tItems.infer) attest(tItems.expression).snap("string[]") const tItemsArr = jsonSchemaToType({ type: "array", items: [{ type: "string" }, { type: "number" }] }) + attest<[string, number]>(tItemsArr.infer) attest(tItemsArr.expression).snap("[string, number]") }) @@ -30,17 +33,33 @@ contextualize(() => { type: "array", prefixItems: [{ type: "string" }, { type: "number" }] }) + attest<[string, number, ...unknown[]]>(tPrefixItems.infer) attest(tPrefixItems.expression).snap("[string, number, ...unknown[]]") }) it("items & prefixItems", () => { + const tItemsFalseAndPrefixItems = jsonSchemaToType({ + type: "array", + prefixItems: [{ type: "string" }, { type: "number" }], + items: false + }) + attest<[string, number]>(tItemsFalseAndPrefixItems.infer) + const tItemsAndPrefixItems = jsonSchemaToType({ type: "array", prefixItems: [{ type: "string" }, { type: "number" }], items: { type: "boolean" } }) - attest(tItemsAndPrefixItems.expression).snap( - "[string, number, ...boolean[]]" + attest<[string, number, ...boolean[]]>(tItemsAndPrefixItems.infer) + + const tItemsArrayAndPrefixItems = jsonSchemaToType({ + type: "array", + prefixItems: [{ type: "string" }, { type: "number" }], + items: [{ type: "boolean" }, { type: "null" }] + }) + attest<[string, number, boolean, null]>(tItemsArrayAndPrefixItems.infer) + attest(tItemsArrayAndPrefixItems.expression).snap( + "[string, number, boolean, null]" ) }) @@ -49,6 +68,7 @@ contextualize(() => { type: "array", additionalItems: { type: "string" } }) + attest(tAdditionalItems.infer) attest(tAdditionalItems.expression).snap("string[]") }) @@ -58,6 +78,7 @@ contextualize(() => { additionalItems: { type: "boolean" }, items: [{ type: "string" }, { type: "number" }] }) + attest<[string, number, ...boolean[]]>(tItemsVariadic.infer) attest(tItemsVariadic.expression).snap("[string, number, ...boolean[]]") const tItemsFalseAdditional = jsonSchemaToType({ @@ -65,14 +86,17 @@ contextualize(() => { additionalItems: false, items: [{ type: "string" }] }) + attest<[string]>(tItemsFalseAdditional.infer) attest(tItemsFalseAdditional.expression).snap("[string]") - attest(() => - jsonSchemaToType({ - type: "array", - additionalItems: { type: "string" }, - items: { type: "string" } - }) + attest( + () => + // @ts-ignore Suppress 'excessively deep and possibly infinite' error + jsonSchemaToType({ + type: "array", + additionalItems: { type: "string" }, + items: { type: "string" } + }) as never ).throws(writeJsonSchemaArrayNonArrayItemsAndAdditionalItemsMessage()) }) @@ -88,13 +112,14 @@ contextualize(() => { }) it("additionalItems & items & prefixItems", () => { - attest(() => - jsonSchemaToType({ - type: "array", - additionalItems: { type: "boolean" }, - items: { type: "null" }, - prefixItems: [{ type: "string" }, { type: "number" }] - }) + attest( + () => + jsonSchemaToType({ + type: "array", + additionalItems: { type: "boolean" }, + items: { type: "null" }, + prefixItems: [{ type: "string" }, { type: "number" }] + }) as never ).throws(writeJsonSchemaArrayAdditionalItemsAndItemsAndPrefixItemsMessage()) }) @@ -103,6 +128,7 @@ contextualize(() => { type: "array", contains: { type: "number" } }) + attest(tContains.infer) attest(tContains.json).snap({ proto: "Array", predicate: ["$ark.jsonSchemaArrayContainsValidator"] @@ -117,13 +143,14 @@ contextualize(() => { type: "array", maxItems: 5 }) + attest(tMaxItems.infer) attest(tMaxItems.expression).snap("Array <= 5") }) it("maxItems (negative)", () => { - attest(() => jsonSchemaToType({ type: "array", maxItems: -1 })).throws( - "TraversalError: maxItems must be non-negative" - ) + attest( + () => jsonSchemaToType({ type: "array", maxItems: -1 }) as never + ).throws("TraversalError: maxItems must be non-negative") }) it("minItems (positive)", () => { @@ -131,13 +158,14 @@ contextualize(() => { type: "array", minItems: 5 }) + attest(tMinItems.infer) attest(tMinItems.expression).snap("Array >= 5") }) it("minItems (negative)", () => { - attest(() => jsonSchemaToType({ type: "array", minItems: -1 })).throws( - "TraversalError: minItems must be non-negative" - ) + attest( + () => jsonSchemaToType({ type: "array", minItems: -1 }) as never + ).throws("TraversalError: minItems must be non-negative") }) it("minItems (0)", () => { @@ -155,6 +183,7 @@ contextualize(() => { type: "array", uniqueItems: true }) + attest(tUniqueItems.infer) attest(tUniqueItems.json).snap({ proto: "Array", predicate: ["$ark.jsonSchemaArrayUniqueItemsValidator"] diff --git a/ark/json-schema/__tests__/composition.test.ts b/ark/json-schema/__tests__/composition.test.ts index ce12b54d4d..d540f86457 100644 --- a/ark/json-schema/__tests__/composition.test.ts +++ b/ark/json-schema/__tests__/composition.test.ts @@ -9,21 +9,24 @@ contextualize(() => { { type: "string", maxLength: 10 } ] }) + attest(tAllOf.infer) attest(tAllOf.expression).snap("string <= 10 & >= 1") }) it("anyOf", () => { const tAnyOf = jsonSchemaToType({ anyOf: [ - { type: "string", minLength: 1 }, - { type: "string", maxLength: 10 } + { type: "string", minLength: 1, maxLength: 1 }, + { type: "number", maximum: 9 } ] }) - attest(tAnyOf.expression).snap("string <= 10 | string >= 1") + attest(tAnyOf.infer) + attest(tAnyOf.expression).snap("number <= 9 | string == 1") }) it("not", () => { const tNot = jsonSchemaToType({ not: { type: "string", maxLength: 3 } }) + attest(tNot.infer) attest(tNot.json).snap({ predicate: ["$ark.jsonSchemaNotValidator"] }) @@ -39,6 +42,7 @@ contextualize(() => { const tOneOf = jsonSchemaToType({ oneOf: [{ type: "string", minLength: 10 }, { const: "foo" }] }) + attest(tOneOf.infer) attest(tOneOf.json).snap({ predicate: ["$ark.jsonSchemaOneOfValidator"] }) diff --git a/ark/json-schema/__tests__/object.test.ts b/ark/json-schema/__tests__/object.test.ts index c2f12da320..16ad860222 100644 --- a/ark/json-schema/__tests__/object.test.ts +++ b/ark/json-schema/__tests__/object.test.ts @@ -5,10 +5,12 @@ import { writeJsonSchemaObjectNonConformingPatternAndPropertyNamesMessage } from "@ark/json-schema" import { writeDuplicateKeyMessage } from "@ark/schema" +import type { Json } from "@ark/util" contextualize(() => { it("type object", () => { const t = jsonSchemaToType({ type: "object" }) + attest>(t.infer) attest(t.expression).snap("{}") attest(t.allows({ foo: 3 })) }) @@ -18,6 +20,7 @@ contextualize(() => { type: "object", maxProperties: 1 }) + attest>(tMaxProperties.infer) attest(tMaxProperties.json).snap({ domain: "object", predicate: ["$ark.jsonSchemaObjectMaxPropertiesValidator"] @@ -33,6 +36,7 @@ contextualize(() => { type: "object", minProperties: 2 }) + attest>(tMinProperties.infer) attest(tMinProperties.json).snap({ domain: "object", predicate: ["$ark.jsonSchemaObjectMinPropertiesValidator"] @@ -52,28 +56,31 @@ contextualize(() => { }, required: ["foo"] }) + attest<{ foo: string; bar?: number }>(tRequired.infer) attest(tRequired.expression).snap("{ foo: string, bar?: number }") - attest(() => - jsonSchemaToType({ type: "object", required: ["foo"] }) + attest( + () => jsonSchemaToType({ type: "object", required: ["foo"] }) as never ).throws( "TraversalError: must be a valid object JSON Schema (was an object JSON Schema with 'required' array but no 'properties' object)" ) - attest(() => - jsonSchemaToType({ - type: "object", - properties: { foo: { type: "string" } }, - required: ["bar"] - }) + attest( + () => + jsonSchemaToType({ + type: "object", + properties: { foo: { type: "string" } }, + required: ["bar"] + }) as never ).throws( `TraversalError: required must be a key from the 'properties' object, i.e. foo (was bar)` ) - attest(() => - jsonSchemaToType({ - type: "object", - properties: { foo: { type: "string" } }, - required: ["foo", "foo"] - }) + attest( + () => + jsonSchemaToType({ + type: "object", + properties: { foo: { type: "string" } }, + required: ["foo", "foo"] + }) as never ).throws(writeDuplicateKeyMessage("foo")) }) @@ -83,6 +90,7 @@ contextualize(() => { additionalProperties: { type: "number" }, properties: { bar: { type: "string" } } }) + attest<{ bar?: string } & Record>(tAdditionalProperties.infer) attest(tAdditionalProperties.json).snap({ domain: "object", optional: [{ key: "bar", value: "string" }], @@ -101,6 +109,7 @@ contextualize(() => { "^[a-z]+$": { type: "string" } } }) + attest>(tPatternProperties.infer) attest(tPatternProperties.expression).snap("{ [/^[a-z]+$/]: string }") attest(tPatternProperties.allows({})).equals(true) attest(tPatternProperties.allows({ foo: "bar" })).equals(true) @@ -118,7 +127,6 @@ contextualize(() => { ) attest(() => - // @ts-expect-error jsonSchemaToType({ type: "object", propertyNames: { type: "number" } diff --git a/ark/json-schema/__tests__/string.test.ts b/ark/json-schema/__tests__/string.test.ts index a024564936..af4abdf7ae 100644 --- a/ark/json-schema/__tests__/string.test.ts +++ b/ark/json-schema/__tests__/string.test.ts @@ -5,6 +5,7 @@ import type { JsonSchemaOrBoolean } from "@ark/schema" contextualize(() => { it("type string", () => { const t = jsonSchemaToType({ type: "string" }) + attest(t.infer) attest(t.expression).snap("string") }) @@ -13,16 +14,18 @@ contextualize(() => { type: "string", maxLength: 5 }) + attest(tMaxLength.infer) attest(tMaxLength.expression).snap("string <= 5") }) it("maxLength (negative)", () => { const maxLength = -5 - attest(() => - jsonSchemaToType({ - type: "string", - maxLength - }) + attest( + () => + jsonSchemaToType({ + type: "string", + maxLength + }) as never ).throws( `TraversalError: maxLength must be non-negative (was ${maxLength})` ) @@ -33,16 +36,18 @@ contextualize(() => { type: "string", minLength: 5 }) + attest(tMinLength.infer) attest(tMinLength.expression).snap("string >= 5") }) it("minLength (negative)", () => { const minLength = -1 - attest(() => - jsonSchemaToType({ - type: "string", - minLength - }) + attest( + () => + jsonSchemaToType({ + type: "string", + minLength + }) as never ).throws( `TraversalError: minLength must be non-negative (was ${minLength})` ) @@ -53,6 +58,7 @@ contextualize(() => { type: "string", pattern: "es" }) + attest(tPatternString.infer) attest(tPatternString.expression).snap("/es/") // JSON Schema explicitly specifies that regexes MUST NOT be implicitly anchored // https://json-schema.org/draft-07/draft-handrews-json-schema-validation-01#rfc.section.4.3 diff --git a/ark/json-schema/array.ts b/ark/json-schema/array.ts index 4455638bf8..ef07fa4e8c 100644 --- a/ark/json-schema/array.ts +++ b/ark/json-schema/array.ts @@ -2,18 +2,85 @@ import { describeBranches, rootSchema, type Intersection, + type makeRootAndArrayPropertiesMutable, type Predicate, type Traversal } from "@ark/schema" -import { printable, throwParseError } from "@ark/util" +import { printable, throwParseError, type array, type Json } from "@ark/util" import { type, type JsonSchema, type Out, type Type } from "arktype" import { writeJsonSchemaArrayAdditionalItemsAndItemsAndPrefixItemsMessage, writeJsonSchemaArrayNonArrayItemsAndAdditionalItemsMessage } from "./errors.ts" -import { jsonSchemaToType } from "./json.ts" +import { jsonSchemaToType, type inferJsonSchema } from "./json.ts" import { JsonSchemaScope } from "./scope.ts" +type inferArrayOfJsonSchema> = + makeRootAndArrayPropertiesMutable<{ + [index in keyof tuple]: inferJsonSchema + }> + +type inferJsonSchemaArrayItems = + arrayItemsSchema extends array ? + arrayItemsSchema["length"] extends 0 ? + // JSON Schema explicitly states that {items: []} means "an array of anything" + // https://json-schema.org/understanding-json-schema/reference/array#items + Json[] + : arrayItemsSchema extends array ? + inferArrayOfJsonSchema + : never + : inferJsonSchema[] + +type inferJsonSchemaPrefixWithAdditionalItems< + arraySchema, + prefixKey, + additionalKey +> = + arraySchema[prefixKey & keyof arraySchema] extends array ? + arraySchema[additionalKey & keyof arraySchema] extends false ? + inferJsonSchemaArrayItems + : inferJsonSchemaArrayItems< + [ + ...arraySchema[prefixKey & keyof arraySchema], + ...(arraySchema[additionalKey & keyof arraySchema] extends array ? + arraySchema[additionalKey & keyof arraySchema] + : arraySchema[additionalKey & keyof arraySchema][]) + ] + > + : never +export type inferJsonSchemaArray = + "prefixItems" extends keyof arraySchema ? + "additionalItems" extends keyof arraySchema ? + "items" extends keyof arraySchema ? + never + : inferJsonSchemaPrefixWithAdditionalItems< + arraySchema, + "prefixItems", + "additionalItems" + > + : "items" extends keyof arraySchema ? + inferJsonSchemaPrefixWithAdditionalItems< + arraySchema, + "prefixItems", + "items" + > + : // @ts-expect-error This type resolves despite giving a 'excessively deep and possibly infinite' error + [...inferJsonSchemaArrayItems, ...unknown[]] + : "additionalItems" extends keyof arraySchema ? + "items" extends keyof arraySchema ? + inferJsonSchemaPrefixWithAdditionalItems< + arraySchema, + "items", + "additionalItems" + > + : inferJsonSchemaArrayItems // only has additionalItems + : "items" extends ( + keyof arraySchema // only has items + ) ? + inferJsonSchemaArrayItems + : T extends array ? T + : unknown[] + const deepNormalize = (data: unknown): unknown => typeof data === "object" ? data === null ? null @@ -71,29 +138,47 @@ export const parseArrayJsonSchema: Type< } let itemsIsPrefixItems = false + let itemsHaveBeenPreProcessed = false + if ("prefixItems" in jsonSchema) { if ("items" in jsonSchema) { if ("additionalItems" in jsonSchema) { throwParseError( writeJsonSchemaArrayAdditionalItemsAndItemsAndPrefixItemsMessage() ) - } else jsonSchema.additionalItems = jsonSchema.items + } + if (Array.isArray(jsonSchema.items)) { + arktypeArraySchema.sequence = { + prefix: [...jsonSchema.prefixItems, ...jsonSchema.items].map( + item => jsonSchemaToType(item as never).internal + ) + } + itemsHaveBeenPreProcessed = true + } else { + jsonSchema.additionalItems = jsonSchema.items + jsonSchema.items = jsonSchema.prefixItems + itemsIsPrefixItems = true + } + } else { + jsonSchema.items = jsonSchema.prefixItems + itemsIsPrefixItems = true } - jsonSchema.items = jsonSchema.prefixItems - itemsIsPrefixItems = true } - if ("items" in jsonSchema) { + if (!itemsHaveBeenPreProcessed && "items" in jsonSchema) { if (Array.isArray(jsonSchema.items)) { arktypeArraySchema.sequence = { - prefix: jsonSchema.items.map(item => jsonSchemaToType(item).internal) + prefix: jsonSchema.items.map( + item => jsonSchemaToType(item as never).internal + ) } if ("additionalItems" in jsonSchema) { if (jsonSchema.additionalItems !== false) { arktypeArraySchema.sequence = { ...arktypeArraySchema.sequence, - variadic: jsonSchemaToType(jsonSchema.additionalItems).internal + variadic: jsonSchemaToType(jsonSchema.additionalItems as never) + .internal } } } else if (itemsIsPrefixItems) { @@ -109,12 +194,12 @@ export const parseArrayJsonSchema: Type< ) } arktypeArraySchema.sequence = { - variadic: jsonSchemaToType(jsonSchema.items).internal + variadic: jsonSchemaToType(jsonSchema.items as never).internal } } } else if ("additionalItems" in jsonSchema) { arktypeArraySchema.sequence = { - variadic: jsonSchemaToType(jsonSchema.additionalItems).internal + variadic: jsonSchemaToType(jsonSchema.additionalItems as never).internal } } @@ -128,7 +213,9 @@ export const parseArrayJsonSchema: Type< predicates.push(jsonSchemaArrayUniqueItemsValidator) if ("contains" in jsonSchema) { - const parsedContainsJsonSchema = jsonSchemaToType(jsonSchema.contains) + const parsedContainsJsonSchema = jsonSchemaToType( + jsonSchema.contains as never + ) predicates.push(arrayContainsItemMatchingSchema(parsedContainsJsonSchema)) } diff --git a/ark/json-schema/composition.ts b/ark/json-schema/composition.ts index 7eddb25c95..357fb1c117 100644 --- a/ark/json-schema/composition.ts +++ b/ark/json-schema/composition.ts @@ -1,22 +1,51 @@ import type { Traversal } from "@ark/schema" -import { printable } from "@ark/util" +import { printable, type array } from "@ark/util" import { type, type JsonSchema, type Type } from "arktype" -import { jsonSchemaToType } from "./json.ts" +import { jsonSchemaToType, type inferJsonSchema } from "./json.ts" + +// NB: For simplicity sake, the type level treats 'anyOf' and 'oneOf' as the same. +type inferJsonSchemaAnyOrOneOf = + compositionSchemaValue extends never[] ? + never // is an empty array, so is invalid + : compositionSchemaValue extends array ? + t & inferJsonSchema + : never // is not an array, so is invalid + +export type inferJsonSchemaComposition = + "allOf" extends keyof schema ? + t extends never ? + t // "allOf" has incompatible schemas, so don't keep looking + : schema["allOf"] extends ( + readonly [infer firstSchema, ...infer restOfSchemas] + ) ? + inferJsonSchemaComposition< + { allOf: restOfSchemas }, + inferJsonSchema + > + : schema["allOf"] extends never[] ? + t // have finished inferring schemas + : never // "allOf" isn't an array, so is invalid + : "oneOf" extends keyof schema ? inferJsonSchemaAnyOrOneOf + : "anyOf" extends keyof schema ? inferJsonSchemaAnyOrOneOf + : "not" extends keyof schema ? + t // NB: TypeScript doesn't have "not" types, so can't accurately represent. + : never const parseAllOfJsonSchema = (jsonSchemas: readonly JsonSchema[]): Type => jsonSchemas - .map(jsonSchema => jsonSchemaToType(jsonSchema)) + // @ts-ignore Suppress 'excessivevely deep and possibly infinite' error + .map(jsonSchema => jsonSchemaToType(jsonSchema as never)) .reduce((acc, validator) => acc.and(validator)) export const parseAnyOfJsonSchema = ( jsonSchemas: readonly JsonSchema[] ): Type => jsonSchemas - .map(jsonSchema => jsonSchemaToType(jsonSchema)) + .map(jsonSchema => jsonSchemaToType(jsonSchema as never)) .reduce((acc, validator) => acc.or(validator)) const parseNotJsonSchema = (jsonSchema: JsonSchema): Type => { - const inner = jsonSchemaToType(jsonSchema) + const inner = jsonSchemaToType(jsonSchema as never) const jsonSchemaNotValidator = (data: unknown, ctx: Traversal) => inner.allows(data) ? @@ -30,7 +59,7 @@ const parseNotJsonSchema = (jsonSchema: JsonSchema): Type => { const parseOneOfJsonSchema = (jsonSchemas: readonly JsonSchema[]): Type => { const oneOfValidators = jsonSchemas.map(nestedSchema => - jsonSchemaToType(nestedSchema) + jsonSchemaToType(nestedSchema as never) ) const oneOfValidatorsDescriptions = oneOfValidators.map( validator => `○ ${validator.description}` diff --git a/ark/json-schema/json.ts b/ark/json-schema/json.ts index 87764c7f36..fa0f3046ff 100644 --- a/ark/json-schema/json.ts +++ b/ark/json-schema/json.ts @@ -1,20 +1,56 @@ import { describeBranches, type JsonSchemaOrBoolean } from "@ark/schema" -import { printable, throwParseError } from "@ark/util" +import { + printable, + throwParseError, + type array, + type ErrorMessage, + type Json +} from "@ark/util" import { type, type JsonSchema } from "arktype" -import { parseArrayJsonSchema } from "./array.ts" +import { parseArrayJsonSchema, type inferJsonSchemaArray } from "./array.ts" import { parseCommonJsonSchema } from "./common.ts" import { parseAnyOfJsonSchema, - parseCompositionJsonSchema + parseCompositionJsonSchema, + type inferJsonSchemaComposition } from "./composition.ts" import { writeJsonSchemaInsufficientKeysMessage, writeJsonSchemaUnsupportedTypeMessage } from "./errors.ts" import { parseNumberJsonSchema } from "./number.ts" -import { parseObjectJsonSchema } from "./object.ts" +import { parseObjectJsonSchema, type inferJsonSchemaObject } from "./object.ts" import { JsonSchemaScope } from "./scope.ts" -import { parseStringJsonSchema } from "./string.ts" +import { parseStringJsonSchema, type inferJsonSchemaString } from "./string.ts" + +type JsonSchemaConstraintKind = "const" | "enum" +type JsonSchemaConst = { const: t } +type JsonSchemaEnum = { enum: readonly t[] } + +type inferJsonSchemaConstraint< + schema, + t, + kind extends JsonSchemaConstraintKind +> = t extends never ? never : t & inferJsonSchema> + +export type inferJsonSchema = + schema extends true | Record ? Json + : schema extends false ? never + : schema extends array ? inferJsonSchema + : schema extends JsonSchema.Composition ? + inferJsonSchemaComposition + : schema extends JsonSchemaConst ? + inferJsonSchemaConstraint + : schema extends JsonSchemaEnum ? + inferJsonSchemaConstraint + : schema extends { type: "boolean" } ? t & boolean + : schema extends { type: "null" } ? t & null + : schema extends JsonSchema.Numeric ? t & number + : schema extends JsonSchema.Array ? t & inferJsonSchemaArray + : schema extends JsonSchema.Object ? t & inferJsonSchemaObject + : schema extends JsonSchema.String ? t & inferJsonSchemaString + : t extends {} ? t + : ErrorMessage<"Failed to infer JSON Schema"> const jsonSchemaTypeMatcher = type.match .in>() @@ -89,6 +125,6 @@ export const innerParseJsonSchema = JsonSchemaScope.Schema.pipe( } ) -export const jsonSchemaToType = ( - jsonSchema: JsonSchemaOrBoolean -): type => innerParseJsonSchema.assert(jsonSchema) as never +export const jsonSchemaToType = ( + jsonSchema: t +): type> => innerParseJsonSchema.assert(jsonSchema) as never diff --git a/ark/json-schema/object.ts b/ark/json-schema/object.ts index 5250943de1..1a44338429 100644 --- a/ark/json-schema/object.ts +++ b/ark/json-schema/object.ts @@ -7,16 +7,82 @@ import { type Predicate, type Traversal } from "@ark/schema" -import { printable, throwParseError } from "@ark/util" +import { printable, throwParseError, type Json, type show } from "@ark/util" import { type, type JsonSchema, type Out, type Type } from "arktype" import { writeJsonSchemaObjectNonConformingKeyAndPropertyNamesMessage, writeJsonSchemaObjectNonConformingPatternAndPropertyNamesMessage } from "./errors.ts" -import { jsonSchemaToType } from "./json.ts" +import { jsonSchemaToType, type inferJsonSchema } from "./json.ts" import { JsonSchemaScope } from "./scope.ts" +type inferAdditionalProperties = + objectSchema["additionalProperties" & keyof objectSchema] extends ( + JsonSchema.Branch + ) ? + objectSchema["additionalProperties" & keyof objectSchema] extends false ? + // false means no additional properties are allowed, + // which is the default in TypeScript so just return the current type. + unknown + : { + // In TS it's only possible to accurately infer additional properties + // when the non-additional types extends the additional properties type. + // This logic is too complicated for now, so as a compromise we use the Json + // type which will at least allow unspecified properties at runtime. + [key: string]: Json + } + : never + +type inferRequiredProperties = { + [P in (objectSchema["required" & keyof objectSchema] & + string[])[number]]: P extends ( + keyof objectSchema["properties" & keyof objectSchema] + ) ? + objectSchema["properties" & keyof objectSchema][P] extends JsonSchema ? + inferJsonSchema + : never + : never +} + +type inferOptionalProperties = { + [P in keyof objectSchema["properties" & + keyof objectSchema]]?: objectSchema["properties" & + keyof objectSchema][P] extends JsonSchema ? + inferJsonSchema + : never +} + +// NB: We don't infer `patternProperties` or 'patternProperties' since regex index signatures are not supported in TS +export type inferJsonSchemaObject = + "properties" extends keyof objectSchema ? + "required" extends keyof objectSchema ? + inferJsonSchemaObject< + Omit & { + properties: Omit< + // Remove the required keys + objectSchema["properties"], + (objectSchema["required"] & string[])[number] + > + }, + inferRequiredProperties + > + : // 'required' isn't present, so all properties are optional + inferJsonSchemaObject< + Omit, + inferOptionalProperties extends ( + Record + ) ? + T + : T & inferOptionalProperties + > + : "required" extends keyof objectSchema ? never + : "additionalProperties" extends keyof objectSchema ? + show> + : // additionalProperties isn't present in the schema, which JSON Schema explicitly + // states means extra properties are allowed, so update types accordingly. + show + const parseMinMaxProperties = ( jsonSchema: JsonSchema.Object, ctx: Traversal @@ -70,7 +136,8 @@ const parsePatternProperties = (jsonSchema: JsonSchema.Object) => { if (!("patternProperties" in jsonSchema)) return const patternProperties = Object.entries(jsonSchema.patternProperties).map( - ([key, value]) => [new RegExp(key), jsonSchemaToType(value)] as const + ([key, value]) => + [new RegExp(key), jsonSchemaToType(value as never)] as const ) // NB: We don't validate compatibility of schemas for overlapping patternProperties @@ -125,11 +192,11 @@ const parseRequiredAndOptionalKeys = ( return { optionalKeys: optionalKeys.map(key => ({ key, - value: jsonSchemaToType(jsonSchema.properties![key]).internal + value: jsonSchemaToType(jsonSchema.properties![key] as never).internal })), requiredKeys: requiredKeys.map(key => ({ key, - value: jsonSchemaToType(jsonSchema.properties![key]).internal + value: jsonSchemaToType(jsonSchema.properties![key] as never).internal })) } } @@ -166,7 +233,7 @@ const parseAdditionalProperties = (jsonSchema: JsonSchema.Object) => { continue const additionalPropertyValidator = jsonSchemaToType( - additionalPropertiesSchema + additionalPropertiesSchema as never ) const value = data[key as keyof typeof data] diff --git a/ark/json-schema/string.ts b/ark/json-schema/string.ts index 76c71a990b..c83d19313c 100644 --- a/ark/json-schema/string.ts +++ b/ark/json-schema/string.ts @@ -2,6 +2,8 @@ import { rootSchema, type Intersection } from "@ark/schema" import type { Out, Type } from "arktype" import { JsonSchemaScope, type StringSchema } from "./scope.ts" +export type inferJsonSchemaString = string + export const parseStringJsonSchema: Type< (In: StringSchema) => Out>, any